ramadan-cal 1.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.
- package/README.md +36 -0
- package/bin/cli.js +48 -0
- package/package.json +36 -0
- package/scripts/postinstall.js +125 -0
- package/src/calendar.js +45 -0
- package/src/check-location.js +112 -0
- package/src/config.js +261 -0
- package/src/display.js +117 -0
- package/src/prayer.js +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# ๐ ramadan-cal
|
|
2
|
+
|
|
3
|
+
A beautiful terminal Ramadan calendar with prayer times, daily progress, and Hijri date tracking.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g ramadan-cal
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
ramadan # Full display: prayer times + calendar
|
|
15
|
+
ramadan times # Prayer times only
|
|
16
|
+
ramadan calendar # Calendar only
|
|
17
|
+
ramadan check # Check prayer times for any city
|
|
18
|
+
ramadan eid 2026-03-19 # Set the Eid date when announced
|
|
19
|
+
ramadan reset # Clear config and start fresh
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
First run auto-detects your location and asks one question โ when the moon was sighted. That's it.
|
|
25
|
+
|
|
26
|
+
Prayer times are calculated offline using the [adhan](https://github.com/batoulapps/adhan-js) library. No API calls needed after setup.
|
|
27
|
+
|
|
28
|
+
## Claude Code
|
|
29
|
+
|
|
30
|
+
During installation you'll be asked if you want to set up Claude Code integration. If enabled, type `ramadan` inside any Claude Code session to see your calendar โ zero tokens used.
|
|
31
|
+
|
|
32
|
+
If you skipped during install, run `npm rebuild ramadan-cal` to set it up later.
|
|
33
|
+
|
|
34
|
+
## License
|
|
35
|
+
|
|
36
|
+
MIT
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { getConfig, resetConfig, setEidDate } from '../src/config.js';
|
|
4
|
+
import { render } from '../src/display.js';
|
|
5
|
+
import { checkLocation } from '../src/check-location.js';
|
|
6
|
+
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const command = args[0]?.toLowerCase();
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
// Handle reset
|
|
12
|
+
if (command === 'reset') {
|
|
13
|
+
resetConfig();
|
|
14
|
+
console.log('\nโ
Config cleared. Run `ramadan` to set up again.\n');
|
|
15
|
+
process.exit(0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Handle eid date setting
|
|
19
|
+
if (command === 'eid') {
|
|
20
|
+
const dateStr = args[1];
|
|
21
|
+
if (!dateStr || !/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
|
22
|
+
console.log('\nโ Please provide a date: ramadan eid YYYY-MM-DD\n');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
setEidDate(dateStr);
|
|
26
|
+
console.log(`\nโ
Eid date set to ${dateStr}\n`);
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Handle location check
|
|
31
|
+
if (command === 'check') {
|
|
32
|
+
await checkLocation();
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Get or create config (will prompt on first run)
|
|
37
|
+
const config = await getConfig();
|
|
38
|
+
|
|
39
|
+
// Determine which view to show
|
|
40
|
+
const view = command === 'times' ? 'times' : command === 'calendar' ? 'calendar' : 'full';
|
|
41
|
+
|
|
42
|
+
render(config, view);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
main().catch((err) => {
|
|
46
|
+
console.error('\nโ Something went wrong:', err.message, '\n');
|
|
47
|
+
process.exit(1);
|
|
48
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ramadan-cal",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "๐ A beautiful terminal Ramadan calendar with prayer times, daily progress, and Hijri date",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ramadan": "bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/cli.js",
|
|
11
|
+
"link": "npm link",
|
|
12
|
+
"postinstall": "node scripts/postinstall.js"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"ramadan",
|
|
16
|
+
"islam",
|
|
17
|
+
"prayer-times",
|
|
18
|
+
"hijri",
|
|
19
|
+
"calendar",
|
|
20
|
+
"cli"
|
|
21
|
+
],
|
|
22
|
+
"author": "",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"files": [
|
|
25
|
+
"bin",
|
|
26
|
+
"src",
|
|
27
|
+
"scripts",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"adhan": "^4.4.3",
|
|
32
|
+
"chalk": "^5.3.0",
|
|
33
|
+
"conf": "^12.0.0",
|
|
34
|
+
"prompts": "^2.4.2"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Postinstall script โ runs automatically after npm install.
|
|
5
|
+
* Interactively sets up Claude Code integration if the user wants it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { homedir } from 'os';
|
|
11
|
+
import { execSync } from 'child_process';
|
|
12
|
+
import { createInterface } from 'readline';
|
|
13
|
+
|
|
14
|
+
const home = homedir();
|
|
15
|
+
const claudeDir = join(home, '.claude');
|
|
16
|
+
const claudeMd = join(claudeDir, 'CLAUDE.md');
|
|
17
|
+
|
|
18
|
+
const MARKER = '<!-- ramadan-cal -->';
|
|
19
|
+
const CLAUDE_ENTRY = `
|
|
20
|
+
${MARKER}
|
|
21
|
+
## Ramadan CLI
|
|
22
|
+
When the user types "ramadan" or any command starting with "ramadan" (like "ramadan times", "ramadan reset", "ramadan test mid", "ramadan eid"), run it as a shell command using bash. Do not answer from general knowledge.
|
|
23
|
+
${MARKER}
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
function isClaudeInstalled() {
|
|
27
|
+
try {
|
|
28
|
+
execSync('which claude', { stdio: 'ignore' });
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isAlreadyConfigured() {
|
|
36
|
+
if (!existsSync(claudeMd)) return false;
|
|
37
|
+
const content = readFileSync(claudeMd, 'utf-8');
|
|
38
|
+
return content.includes(MARKER);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ask(question) {
|
|
42
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
rl.question(question, (answer) => {
|
|
45
|
+
rl.close();
|
|
46
|
+
resolve(answer.trim().toLowerCase());
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function main() {
|
|
52
|
+
console.log('');
|
|
53
|
+
console.log(' ๐ Ramadan CLI installed successfully!');
|
|
54
|
+
console.log(' Run `ramadan` to get started.');
|
|
55
|
+
console.log('');
|
|
56
|
+
|
|
57
|
+
// Check if already configured
|
|
58
|
+
if (isAlreadyConfigured()) {
|
|
59
|
+
console.log(' โ Claude Code integration already configured');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check if Claude is installed
|
|
64
|
+
const claudeInstalled = isClaudeInstalled();
|
|
65
|
+
|
|
66
|
+
if (!claudeInstalled) {
|
|
67
|
+
// Not interactive โ just check if they might want it later
|
|
68
|
+
const answer = await ask(' Do you use Claude Code? (y/n) ');
|
|
69
|
+
|
|
70
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
71
|
+
console.log(' โ Skipping Claude Code setup. You\'re all set!\n');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// They use Claude Code but it's not on PATH
|
|
76
|
+
console.log(' โน Claude Code wasn\'t detected on your PATH.');
|
|
77
|
+
|
|
78
|
+
const proceed = await ask(' Set up the integration anyway? (y/n) ');
|
|
79
|
+
|
|
80
|
+
if (proceed !== 'y' && proceed !== 'yes') {
|
|
81
|
+
console.log(' โ No worries โ run `npm rebuild ramadan-cal` anytime to set it up later.\n');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
console.log(' โ Claude Code detected');
|
|
86
|
+
|
|
87
|
+
const answer = await ask(' Set up Claude Code integration? Type `ramadan` in Claude Code sessions. (y/n) ');
|
|
88
|
+
|
|
89
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
90
|
+
console.log(' โ Skipped. Run `npm rebuild ramadan-cal` anytime to set it up later.\n');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Create .claude directory if needed
|
|
96
|
+
if (!existsSync(claudeDir)) {
|
|
97
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
98
|
+
console.log(' โ Created ~/.claude/');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Write or append to CLAUDE.md
|
|
102
|
+
if (existsSync(claudeMd)) {
|
|
103
|
+
appendFileSync(claudeMd, CLAUDE_ENTRY);
|
|
104
|
+
console.log(' โ Added ramadan command to ~/.claude/CLAUDE.md');
|
|
105
|
+
} else {
|
|
106
|
+
writeFileSync(claudeMd, CLAUDE_ENTRY.trimStart());
|
|
107
|
+
console.log(' โ Created ~/.claude/CLAUDE.md');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.log(' โ Done! Type "ramadan" inside Claude Code and it\'ll just work.\n');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Handle non-interactive environments (CI, piped input)
|
|
114
|
+
if (!process.stdin.isTTY) {
|
|
115
|
+
console.log('');
|
|
116
|
+
console.log(' ๐ Ramadan CLI installed successfully!');
|
|
117
|
+
console.log(' Run `ramadan` to get started.');
|
|
118
|
+
console.log(' Run `npm rebuild ramadan-cal` in an interactive terminal to set up Claude Code.\n');
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
main().catch(() => {
|
|
123
|
+
// Non-fatal โ never block the install
|
|
124
|
+
process.exit(0);
|
|
125
|
+
});
|
package/src/calendar.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
export function renderCalendar(startDate, currentDay, totalDays) {
|
|
4
|
+
const lines = [];
|
|
5
|
+
|
|
6
|
+
// Header
|
|
7
|
+
lines.push(chalk.dim(' Su Mo Tu We Th Fr Sa'));
|
|
8
|
+
|
|
9
|
+
// What day of the week does Ramadan start? (0 = Sunday)
|
|
10
|
+
const startDow = startDate.getDay();
|
|
11
|
+
|
|
12
|
+
// Build cells
|
|
13
|
+
const cells = [];
|
|
14
|
+
|
|
15
|
+
// Leading empty cells
|
|
16
|
+
for (let i = 0; i < startDow; i++) {
|
|
17
|
+
cells.push(' ');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Day cells
|
|
21
|
+
for (let day = 1; day <= totalDays; day++) {
|
|
22
|
+
if (day < currentDay) {
|
|
23
|
+
// Past days โ crossed out
|
|
24
|
+
cells.push(chalk.dim.strikethrough(pad(day)));
|
|
25
|
+
} else if (day === currentDay) {
|
|
26
|
+
// Today โ highlighted
|
|
27
|
+
cells.push(chalk.bold.green(`[${day.toString().padStart(2)}]`));
|
|
28
|
+
} else {
|
|
29
|
+
// Future days
|
|
30
|
+
cells.push(pad(day));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Render rows of 7
|
|
35
|
+
for (let i = 0; i < cells.length; i += 7) {
|
|
36
|
+
const row = cells.slice(i, i + 7);
|
|
37
|
+
lines.push(row.join(''));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return lines.join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function pad(day) {
|
|
44
|
+
return ` ${day.toString().padStart(2)} `;
|
|
45
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import prompts from 'prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { getPrayerTimes } from './prayer.js';
|
|
4
|
+
|
|
5
|
+
const TIMEZONE_METHODS = {
|
|
6
|
+
'america': 'NorthAmerica',
|
|
7
|
+
'europe': 'MuslimWorldLeague',
|
|
8
|
+
'africa': 'Egyptian',
|
|
9
|
+
'asia/dubai': 'UmmAlQura',
|
|
10
|
+
'asia/riyadh': 'UmmAlQura',
|
|
11
|
+
'asia/karachi': 'Karachi',
|
|
12
|
+
'asia/kolkata': 'Karachi',
|
|
13
|
+
'asia/dhaka': 'Karachi',
|
|
14
|
+
'asia/singapore': 'Singapore',
|
|
15
|
+
'asia/kuala': 'Singapore',
|
|
16
|
+
'asia/jakarta': 'Singapore',
|
|
17
|
+
'asia/istanbul': 'Turkey',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function guessMethod(lat, lng) {
|
|
21
|
+
// Rough geographic guess
|
|
22
|
+
if (lng > -130 && lng < -30) return 'NorthAmerica';
|
|
23
|
+
if (lng > -15 && lng < 40 && lat > 35) return 'MuslimWorldLeague';
|
|
24
|
+
if (lng > -20 && lng < 55 && lat < 35 && lat > -35) return 'Egyptian';
|
|
25
|
+
if (lng > 35 && lng < 60 && lat > 10 && lat < 45) return 'UmmAlQura';
|
|
26
|
+
if (lng > 60 && lng < 100) return 'Karachi';
|
|
27
|
+
if (lng > 95 && lng < 145) return 'Singapore';
|
|
28
|
+
return 'MuslimWorldLeague';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function geocode(query) {
|
|
32
|
+
try {
|
|
33
|
+
const encoded = encodeURIComponent(query);
|
|
34
|
+
const res = await fetch(
|
|
35
|
+
`https://nominatim.openstreetmap.org/search?q=${encoded}&format=json&limit=5`,
|
|
36
|
+
{ headers: { 'User-Agent': 'ramadan-cal/1.0' }, signal: AbortSignal.timeout(8000) }
|
|
37
|
+
);
|
|
38
|
+
const data = await res.json();
|
|
39
|
+
return data.map((r) => ({
|
|
40
|
+
name: r.display_name.split(',').slice(0, 3).join(','),
|
|
41
|
+
lat: parseFloat(r.lat),
|
|
42
|
+
lng: parseFloat(r.lon),
|
|
43
|
+
}));
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function checkLocation() {
|
|
50
|
+
console.log('');
|
|
51
|
+
console.log(chalk.bold.cyan('๐ Check Prayer Times'));
|
|
52
|
+
console.log(chalk.dim('Type any city or country to check prayer times.\n'));
|
|
53
|
+
|
|
54
|
+
while (true) {
|
|
55
|
+
const { query } = await prompts({
|
|
56
|
+
type: 'text',
|
|
57
|
+
name: 'query',
|
|
58
|
+
message: 'City or country (or "quit" to exit)',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (!query || query.toLowerCase() === 'quit' || query.toLowerCase() === 'q') {
|
|
62
|
+
console.log(chalk.dim('\n๐ Done.\n'));
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log(chalk.dim(`\n Searching for "${query}"...`));
|
|
67
|
+
const results = await geocode(query);
|
|
68
|
+
|
|
69
|
+
if (results.length === 0) {
|
|
70
|
+
console.log(chalk.red(' Could not find that location. Try again.\n'));
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let selected;
|
|
75
|
+
|
|
76
|
+
if (results.length === 1) {
|
|
77
|
+
selected = results[0];
|
|
78
|
+
console.log(chalk.dim(` Found: ${selected.name}`));
|
|
79
|
+
} else {
|
|
80
|
+
const { pick } = await prompts({
|
|
81
|
+
type: 'select',
|
|
82
|
+
name: 'pick',
|
|
83
|
+
message: 'Which location?',
|
|
84
|
+
choices: results.map((r, i) => ({ title: r.name, value: i })),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (pick === undefined) continue;
|
|
88
|
+
selected = results[pick];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const method = guessMethod(selected.lat, selected.lng);
|
|
92
|
+
const config = {
|
|
93
|
+
latitude: selected.lat,
|
|
94
|
+
longitude: selected.lng,
|
|
95
|
+
method,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const times = getPrayerTimes(config);
|
|
99
|
+
|
|
100
|
+
console.log('');
|
|
101
|
+
console.log(chalk.bold(` ๐ Prayer Times โ ${selected.name.split(',')[0]}`));
|
|
102
|
+
console.log(chalk.dim(` ${selected.lat.toFixed(4)}, ${selected.lng.toFixed(4)} ยท Method: ${method}`));
|
|
103
|
+
console.log('');
|
|
104
|
+
console.log(` Fajr ${chalk.white(times.fajr)} Maghrib ${chalk.white(times.maghrib)}`);
|
|
105
|
+
console.log(` Sunrise ${chalk.white(times.sunrise)} Isha ${chalk.white(times.isha)}`);
|
|
106
|
+
console.log(` Dhuhr ${chalk.white(times.dhuhr)}`);
|
|
107
|
+
console.log(` Asr ${chalk.white(times.asr)}`);
|
|
108
|
+
console.log('');
|
|
109
|
+
console.log(` ๐ฝ๏ธ Suhoor ends: ${chalk.bold.yellow(times.suhoorEnds)} ยท Iftar: ${chalk.bold.green(times.iftar)}`);
|
|
110
|
+
console.log('');
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import Conf from 'conf';
|
|
2
|
+
import prompts from 'prompts';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
|
|
5
|
+
const store = new Conf({
|
|
6
|
+
projectName: 'ramadan-cal',
|
|
7
|
+
schema: {
|
|
8
|
+
city: { type: 'string' },
|
|
9
|
+
country: { type: 'string' },
|
|
10
|
+
ramadanStartDate: { type: 'string' },
|
|
11
|
+
eidDate: { type: 'string', default: '' },
|
|
12
|
+
method: { type: 'string', default: 'NorthAmerica' },
|
|
13
|
+
latitude: { type: 'number' },
|
|
14
|
+
longitude: { type: 'number' },
|
|
15
|
+
timezone: { type: 'string', default: '' },
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const CALCULATION_METHODS = {
|
|
20
|
+
'North America': 'NorthAmerica',
|
|
21
|
+
'Europe': 'MuslimWorldLeague',
|
|
22
|
+
'Middle East': 'UmmAlQura',
|
|
23
|
+
'Egypt / Africa': 'Egyptian',
|
|
24
|
+
'South Asia': 'Karachi',
|
|
25
|
+
'Southeast Asia': 'Singapore',
|
|
26
|
+
'Turkey': 'Turkey',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function guessMethodFromTimezone(tz) {
|
|
30
|
+
if (!tz) return 'MuslimWorldLeague';
|
|
31
|
+
const t = tz.toLowerCase();
|
|
32
|
+
if (t.includes('america')) return 'NorthAmerica';
|
|
33
|
+
if (t.includes('europe') || t.includes('london')) return 'MuslimWorldLeague';
|
|
34
|
+
if (t.includes('asia/dubai') || t.includes('asia/riyadh') || t.includes('asia/qatar') || t.includes('asia/kuwait')) return 'UmmAlQura';
|
|
35
|
+
if (t.includes('africa/cairo') || t.includes('africa')) return 'Egyptian';
|
|
36
|
+
if (t.includes('asia/karachi') || t.includes('asia/dhaka') || t.includes('asia/kolkata')) return 'Karachi';
|
|
37
|
+
if (t.includes('asia/singapore') || t.includes('asia/kuala') || t.includes('asia/jakarta')) return 'Singapore';
|
|
38
|
+
if (t.includes('istanbul') || t.includes('turkey')) return 'Turkey';
|
|
39
|
+
return 'MuslimWorldLeague';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function detectLocation() {
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch('https://ipapi.co/json/', {
|
|
45
|
+
headers: { 'User-Agent': 'ramadan-cal/1.0' },
|
|
46
|
+
signal: AbortSignal.timeout(5000),
|
|
47
|
+
});
|
|
48
|
+
const data = await res.json();
|
|
49
|
+
if (data.latitude && data.longitude) {
|
|
50
|
+
return {
|
|
51
|
+
city: data.city || 'Unknown',
|
|
52
|
+
country: data.country_name || data.country || 'Unknown',
|
|
53
|
+
latitude: data.latitude,
|
|
54
|
+
longitude: data.longitude,
|
|
55
|
+
timezone: data.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// Fall through
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getRamadanStartOptions() {
|
|
65
|
+
// Ramadan 2026 is expected around Feb 17-18
|
|
66
|
+
// Provide a few date options based on likely moon sighting dates
|
|
67
|
+
const year = new Date().getFullYear();
|
|
68
|
+
const candidates = [];
|
|
69
|
+
|
|
70
|
+
// Generate options for a reasonable window
|
|
71
|
+
// For 2026: ~Feb 17-18, for 2025: ~Feb 28 - Mar 1
|
|
72
|
+
if (year === 2025) {
|
|
73
|
+
candidates.push(
|
|
74
|
+
{ title: 'Friday 28th February 2025', value: '2025-02-28' },
|
|
75
|
+
{ title: 'Saturday 1st March 2025', value: '2025-03-01' },
|
|
76
|
+
);
|
|
77
|
+
} else if (year === 2026) {
|
|
78
|
+
candidates.push(
|
|
79
|
+
{ title: 'Tuesday 17th February 2026', value: '2026-02-17' },
|
|
80
|
+
{ title: 'Wednesday 18th February 2026', value: '2026-02-18' },
|
|
81
|
+
);
|
|
82
|
+
} else if (year === 2027) {
|
|
83
|
+
candidates.push(
|
|
84
|
+
{ title: 'Saturday 6th February 2027', value: '2027-02-06' },
|
|
85
|
+
{ title: 'Sunday 7th February 2027', value: '2027-02-07' },
|
|
86
|
+
);
|
|
87
|
+
} else {
|
|
88
|
+
// Fallback: ask for manual input
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
candidates.push({ title: 'Other (enter manually)', value: 'manual' });
|
|
93
|
+
return candidates;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function getConfig() {
|
|
97
|
+
if (store.get('city') && store.get('ramadanStartDate')) {
|
|
98
|
+
return store.store;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.log('\n๐ Welcome to Ramadan CLI\n');
|
|
102
|
+
|
|
103
|
+
// Auto-detect location
|
|
104
|
+
console.log('๐ Detecting your location...');
|
|
105
|
+
const location = await detectLocation();
|
|
106
|
+
|
|
107
|
+
let city, country, latitude, longitude, timezone;
|
|
108
|
+
|
|
109
|
+
if (location) {
|
|
110
|
+
console.log(` Found: ${location.city}, ${location.country}\n`);
|
|
111
|
+
|
|
112
|
+
const confirm = await prompts({
|
|
113
|
+
type: 'confirm',
|
|
114
|
+
name: 'correct',
|
|
115
|
+
message: `Is ${location.city}, ${location.country} correct?`,
|
|
116
|
+
initial: true,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (confirm.correct) {
|
|
120
|
+
city = location.city;
|
|
121
|
+
country = location.country;
|
|
122
|
+
latitude = location.latitude;
|
|
123
|
+
longitude = location.longitude;
|
|
124
|
+
timezone = location.timezone;
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
console.log(' Could not detect automatically.\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Manual location if auto-detect failed or was rejected
|
|
131
|
+
if (!city) {
|
|
132
|
+
const manual = await prompts([
|
|
133
|
+
{
|
|
134
|
+
type: 'text',
|
|
135
|
+
name: 'city',
|
|
136
|
+
message: 'What city are you in?',
|
|
137
|
+
validate: (v) => (v.length > 0 ? true : 'Please enter a city'),
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
type: 'text',
|
|
141
|
+
name: 'country',
|
|
142
|
+
message: 'What country?',
|
|
143
|
+
validate: (v) => (v.length > 0 ? true : 'Please enter a country'),
|
|
144
|
+
},
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
if (!manual.city) {
|
|
148
|
+
console.log('\n๐ Setup cancelled.\n');
|
|
149
|
+
process.exit(0);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
city = manual.city;
|
|
153
|
+
country = manual.country;
|
|
154
|
+
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
155
|
+
|
|
156
|
+
// Geocode
|
|
157
|
+
console.log('\n๐ Finding coordinates...');
|
|
158
|
+
const coords = await geocodeCity(city, country);
|
|
159
|
+
if (coords) {
|
|
160
|
+
latitude = coords.lat;
|
|
161
|
+
longitude = coords.lng;
|
|
162
|
+
console.log(` Found: (${latitude.toFixed(2)}, ${longitude.toFixed(2)})`);
|
|
163
|
+
} else {
|
|
164
|
+
console.log(' Could not find coordinates.');
|
|
165
|
+
const coordInput = await prompts([
|
|
166
|
+
{ type: 'number', name: 'latitude', message: 'Enter latitude:', float: true },
|
|
167
|
+
{ type: 'number', name: 'longitude', message: 'Enter longitude:', float: true },
|
|
168
|
+
]);
|
|
169
|
+
latitude = coordInput.latitude;
|
|
170
|
+
longitude = coordInput.longitude;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Moon sighting date โ the only real question
|
|
175
|
+
console.log('');
|
|
176
|
+
const startOptions = getRamadanStartOptions();
|
|
177
|
+
let ramadanStartDate;
|
|
178
|
+
|
|
179
|
+
if (startOptions) {
|
|
180
|
+
const response = await prompts({
|
|
181
|
+
type: 'select',
|
|
182
|
+
name: 'startDate',
|
|
183
|
+
message: 'When was the moon sighted for Ramadan?',
|
|
184
|
+
choices: startOptions,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (response.startDate === 'manual') {
|
|
188
|
+
const manual = await prompts({
|
|
189
|
+
type: 'text',
|
|
190
|
+
name: 'date',
|
|
191
|
+
message: 'Enter the date (YYYY-MM-DD):',
|
|
192
|
+
validate: (v) => (/^\d{4}-\d{2}-\d{2}$/.test(v) ? true : 'Use format YYYY-MM-DD'),
|
|
193
|
+
});
|
|
194
|
+
ramadanStartDate = manual.date;
|
|
195
|
+
} else {
|
|
196
|
+
ramadanStartDate = response.startDate;
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
const manual = await prompts({
|
|
200
|
+
type: 'text',
|
|
201
|
+
name: 'date',
|
|
202
|
+
message: 'When did Ramadan start? (YYYY-MM-DD)',
|
|
203
|
+
validate: (v) => (/^\d{4}-\d{2}-\d{2}$/.test(v) ? true : 'Use format YYYY-MM-DD'),
|
|
204
|
+
});
|
|
205
|
+
ramadanStartDate = manual.date;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!ramadanStartDate) {
|
|
209
|
+
console.log('\n๐ Setup cancelled.\n');
|
|
210
|
+
process.exit(0);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Eid = 30 days after start
|
|
214
|
+
const startD = new Date(ramadanStartDate + 'T00:00:00');
|
|
215
|
+
const eidD = new Date(startD.getTime() + 30 * 86400000);
|
|
216
|
+
const eidDate = eidD.toISOString().split('T')[0];
|
|
217
|
+
|
|
218
|
+
// Auto-detect calculation method from timezone
|
|
219
|
+
const method = guessMethodFromTimezone(timezone);
|
|
220
|
+
|
|
221
|
+
// Save everything
|
|
222
|
+
store.set('city', city);
|
|
223
|
+
store.set('country', country);
|
|
224
|
+
store.set('latitude', latitude);
|
|
225
|
+
store.set('longitude', longitude);
|
|
226
|
+
store.set('timezone', timezone || '');
|
|
227
|
+
store.set('method', method);
|
|
228
|
+
store.set('ramadanStartDate', ramadanStartDate);
|
|
229
|
+
store.set('eidDate', eidDate);
|
|
230
|
+
|
|
231
|
+
console.log(`\nโ
All set! Eid estimated: ${eidDate}`);
|
|
232
|
+
console.log(` Prayer method: ${Object.keys(CALCULATION_METHODS).find(k => CALCULATION_METHODS[k] === method) || method}`);
|
|
233
|
+
console.log(` Run ${chalk.dim('ramadan eid YYYY-MM-DD')} to update Eid when announced.\n`);
|
|
234
|
+
|
|
235
|
+
return store.store;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function geocodeCity(city, country) {
|
|
239
|
+
try {
|
|
240
|
+
const query = encodeURIComponent(`${city}, ${country}`);
|
|
241
|
+
const res = await fetch(`https://nominatim.openstreetmap.org/search?q=${query}&format=json&limit=1`, {
|
|
242
|
+
headers: { 'User-Agent': 'ramadan-cal/1.0' },
|
|
243
|
+
signal: AbortSignal.timeout(5000),
|
|
244
|
+
});
|
|
245
|
+
const data = await res.json();
|
|
246
|
+
if (data.length > 0) {
|
|
247
|
+
return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
// Fall through
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function resetConfig() {
|
|
256
|
+
store.clear();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function setEidDate(dateStr) {
|
|
260
|
+
store.set('eidDate', dateStr);
|
|
261
|
+
}
|
package/src/display.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { getPrayerTimes } from './prayer.js';
|
|
3
|
+
import { renderCalendar } from './calendar.js';
|
|
4
|
+
|
|
5
|
+
const DIVIDER = chalk.dim('โ'.repeat(38));
|
|
6
|
+
|
|
7
|
+
function getHijriYear(startDate) {
|
|
8
|
+
// Known anchor: Ramadan 2025 starts ~Feb/Mar 2025 = 1446 AH
|
|
9
|
+
// Ramadan 2026 = 1447, 2027 = 1448, etc.
|
|
10
|
+
const year = startDate.getFullYear();
|
|
11
|
+
if (year <= 2025) return 1446;
|
|
12
|
+
return 1446 + (year - 2025);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function daysBetween(a, b) {
|
|
16
|
+
const msPerDay = 86400000;
|
|
17
|
+
const aStart = new Date(a.getFullYear(), a.getMonth(), a.getDate());
|
|
18
|
+
const bStart = new Date(b.getFullYear(), b.getMonth(), b.getDate());
|
|
19
|
+
return Math.round((bStart - aStart) / msPerDay);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatDate(date) {
|
|
23
|
+
return date.toLocaleDateString('en-GB', {
|
|
24
|
+
weekday: 'long',
|
|
25
|
+
day: 'numeric',
|
|
26
|
+
month: 'long',
|
|
27
|
+
year: 'numeric',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function render(config, view = 'full') {
|
|
32
|
+
const start = new Date(config.ramadanStartDate + 'T00:00:00');
|
|
33
|
+
const today = new Date();
|
|
34
|
+
const hijriYear = getHijriYear(start);
|
|
35
|
+
|
|
36
|
+
const endDate = config.eidDate
|
|
37
|
+
? new Date(config.eidDate + 'T00:00:00')
|
|
38
|
+
: new Date(start.getTime() + 30 * 86400000);
|
|
39
|
+
|
|
40
|
+
const totalDays = daysBetween(start, endDate);
|
|
41
|
+
const currentDay = daysBetween(start, today) + 1;
|
|
42
|
+
const daysRemaining = totalDays - currentDay;
|
|
43
|
+
|
|
44
|
+
const output = [];
|
|
45
|
+
|
|
46
|
+
// โโ STATE A: Before Ramadan โโ
|
|
47
|
+
if (currentDay < 1) {
|
|
48
|
+
const daysUntil = Math.abs(currentDay) + 1;
|
|
49
|
+
output.push('');
|
|
50
|
+
output.push(chalk.bold.cyan(`๐ Ramadan ${hijriYear} has not started yet`));
|
|
51
|
+
output.push('');
|
|
52
|
+
output.push(`๐
Starts: ${chalk.white(formatDate(start))}`);
|
|
53
|
+
output.push(`โณ ${chalk.bold.yellow(daysUntil)} days to go`);
|
|
54
|
+
output.push('');
|
|
55
|
+
output.push(chalk.dim('May Allah allow us to reach Ramadan ๐คฒ'));
|
|
56
|
+
output.push('');
|
|
57
|
+
console.log(output.join('\n'));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// โโ STATE C: After Ramadan โโ
|
|
62
|
+
if (currentDay > totalDays) {
|
|
63
|
+
output.push('');
|
|
64
|
+
output.push(chalk.bold.cyan(`๐ Ramadan ${hijriYear} has ended`));
|
|
65
|
+
output.push('');
|
|
66
|
+
output.push(chalk.bold.yellow('๐ Eid Mubarak!'));
|
|
67
|
+
output.push(`๐
Ramadan was ${formatDate(start)} โ ${formatDate(endDate)}`);
|
|
68
|
+
output.push(`โ
${totalDays} days completed`);
|
|
69
|
+
output.push('');
|
|
70
|
+
output.push(chalk.dim('Taqabbal Allahu minna wa minkum ๐คฒ'));
|
|
71
|
+
output.push('');
|
|
72
|
+
console.log(output.join('\n'));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// โโ STATE B: During Ramadan โโ
|
|
77
|
+
const times = getPrayerTimes(config, today);
|
|
78
|
+
|
|
79
|
+
// Header
|
|
80
|
+
output.push('');
|
|
81
|
+
output.push(chalk.bold.cyan(`๐ Ramadan ${hijriYear}`));
|
|
82
|
+
output.push(DIVIDER);
|
|
83
|
+
|
|
84
|
+
// Progress
|
|
85
|
+
output.push(`๐
${chalk.white(`${currentDay} Ramadan ${hijriYear}`)} โ Day ${chalk.bold(currentDay)} of ${totalDays}`);
|
|
86
|
+
output.push(`โณ ${chalk.bold.yellow(daysRemaining)} days remaining`);
|
|
87
|
+
|
|
88
|
+
// Prayer times
|
|
89
|
+
if (view === 'full' || view === 'times') {
|
|
90
|
+
output.push('');
|
|
91
|
+
output.push(`๐ Prayer Times ${chalk.dim(`(${config.city})`)}`);
|
|
92
|
+
output.push(` Fajr ${chalk.white(times.fajr)} Maghrib ${chalk.white(times.maghrib)}`);
|
|
93
|
+
output.push(` Sunrise ${chalk.white(times.sunrise)} Isha ${chalk.white(times.isha)}`);
|
|
94
|
+
output.push(` Dhuhr ${chalk.white(times.dhuhr)}`);
|
|
95
|
+
output.push(` Asr ${chalk.white(times.asr)}`);
|
|
96
|
+
output.push('');
|
|
97
|
+
output.push(`๐ฝ๏ธ Suhoor ends: ${chalk.bold.yellow(times.suhoorEnds)} ยท Iftar: ${chalk.bold.green(times.iftar)}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Calendar
|
|
101
|
+
if (view === 'full' || view === 'calendar') {
|
|
102
|
+
output.push('');
|
|
103
|
+
output.push(DIVIDER);
|
|
104
|
+
output.push(renderCalendar(start, currentDay, totalDays));
|
|
105
|
+
output.push(DIVIDER);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Eid prompt
|
|
109
|
+
if (!config.eidDate && currentDay >= 28) {
|
|
110
|
+
output.push('');
|
|
111
|
+
output.push(chalk.yellow('โ ๏ธ Day 28+ โ Has Eid been announced?'));
|
|
112
|
+
output.push(chalk.dim(' Run: ramadan eid YYYY-MM-DD'));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
output.push('');
|
|
116
|
+
console.log(output.join('\n'));
|
|
117
|
+
}
|
package/src/prayer.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import adhan from 'adhan';
|
|
2
|
+
|
|
3
|
+
const { PrayerTimes, CalculationMethod, Coordinates } = adhan;
|
|
4
|
+
|
|
5
|
+
const METHOD_MAP = {
|
|
6
|
+
NorthAmerica: 'NorthAmerica',
|
|
7
|
+
MuslimWorldLeague: 'MuslimWorldLeague',
|
|
8
|
+
Egyptian: 'Egyptian',
|
|
9
|
+
UmmAlQura: 'UmmAlQura',
|
|
10
|
+
Karachi: 'Karachi',
|
|
11
|
+
MoonsightingCommittee: 'MoonsightingCommittee',
|
|
12
|
+
Singapore: 'Singapore',
|
|
13
|
+
Turkey: 'Turkey',
|
|
14
|
+
Tehran: 'Tehran',
|
|
15
|
+
Dubai: 'Dubai',
|
|
16
|
+
Kuwait: 'Kuwait',
|
|
17
|
+
Qatar: 'Qatar',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function getPrayerTimes(config, date = new Date()) {
|
|
21
|
+
const coordinates = new Coordinates(config.latitude, config.longitude);
|
|
22
|
+
const methodName = METHOD_MAP[config.method] || 'NorthAmerica';
|
|
23
|
+
const params = CalculationMethod[methodName]();
|
|
24
|
+
|
|
25
|
+
const times = new PrayerTimes(coordinates, date, params);
|
|
26
|
+
|
|
27
|
+
const fmt = (d) =>
|
|
28
|
+
d.toLocaleTimeString('en-GB', {
|
|
29
|
+
hour: '2-digit',
|
|
30
|
+
minute: '2-digit',
|
|
31
|
+
hour12: false,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
fajr: fmt(times.fajr),
|
|
36
|
+
sunrise: fmt(times.sunrise),
|
|
37
|
+
dhuhr: fmt(times.dhuhr),
|
|
38
|
+
asr: fmt(times.asr),
|
|
39
|
+
maghrib: fmt(times.maghrib),
|
|
40
|
+
isha: fmt(times.isha),
|
|
41
|
+
suhoorEnds: fmt(times.fajr),
|
|
42
|
+
iftar: fmt(times.maghrib),
|
|
43
|
+
};
|
|
44
|
+
}
|