azaan-cli 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 ADDED
@@ -0,0 +1,76 @@
1
+ # Azaan CLI 🕌
2
+
3
+ <p align="center">
4
+ <img src="./assets/icon.png" width="150" alt="Azaan CLI Logo" />
5
+ </p>
6
+
7
+ A lightweight, beautiful, and interactive CLI to check Azaan times anywhere in the world right from your terminal. Built for humans and status bars.
8
+
9
+ ![Azaan CLI Screenshot](./assets/image.png)
10
+
11
+ ## Install
12
+
13
+ Install globally via npm:
14
+ ```bash
15
+ npm install -g azaan-cli
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ On your first run, the CLI will interactively ask you for your City and Country, and save it in your config.
21
+
22
+ ```bash
23
+ azaan
24
+ ```
25
+
26
+ **Override City (One-off):**
27
+ If you want to quickly check another city without changing your configuration:
28
+ ```bash
29
+ azaan "London"
30
+ azaan "New York"
31
+ ```
32
+
33
+ **Status Bar / Scripts (--status):**
34
+ Output a single-line string of the next prayer and countdown. Perfect for tmux, Waybar, or Polybar.
35
+ ```bash
36
+ azaan --status
37
+ # Example Output: Next prayer is Maghrib in 45 mins and 12 seconds at 05:55 PM
38
+ ```
39
+
40
+ **Configure & Reset:**
41
+ To change your default saved city or calculation method later:
42
+ ```bash
43
+ azaan config
44
+ ```
45
+ To completely clear your local configuration:
46
+ ```bash
47
+ azaan reset
48
+ ```
49
+
50
+ ## Desktop Notifications (Zero Setup)
51
+
52
+ The `azaan-cli` comes with a built-in lightweight background daemon for notifications. **There is zero setup required.**
53
+
54
+ ![Azaan Notification Example](./assets/Notification.png)
55
+
56
+ The moment you run `azaan` in your terminal, the background process starts automatically. It will quietly check the time in the background and send you a native desktop notification **10 minutes before** every Azaan, and exactly **at the time** of the Azaan.
57
+
58
+ ### Managing the Daemon
59
+ You don't need to do anything to start it, but if you want manual control, you can use the daemon commands:
60
+ ```bash
61
+ # Check if the daemon is running
62
+ azaan daemon status
63
+
64
+ # Stop the background notifications
65
+ azaan daemon stop
66
+
67
+ # Start the daemon manually
68
+ azaan daemon start
69
+ ```
70
+
71
+ ## Shoutout 🙌
72
+ A massive thank you and shoutout to [Ahmad Awais](https://github.com/AhmadAwais) for building [ramadan-cli](https://github.com/AhmadAwais/ramadan-cli), which served as the primary inspiration and foundation that led to the creation of Azaan CLI.
73
+
74
+ ## Powered By
75
+ - [Aladhan Prayer Times API](https://aladhan.com/prayer-times-api)
76
+ - `commander` & `@clack/prompts`
Binary file
Binary file
Binary file
package/bin/azaan.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from '../src/cli.js';
4
+
5
+ runCli().catch((err) => {
6
+ console.error('\nAn unexpected error occurred:', err.message);
7
+ process.exit(1);
8
+ });
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "azaan-cli",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "azaan": "./bin/azaan.js"
8
+ },
9
+ "scripts": {
10
+ "test": "echo \"Error: no test specified\" && exit 1"
11
+ },
12
+ "keywords": [
13
+ "azaan",
14
+ "prayer",
15
+ "islam",
16
+ "muslim",
17
+ "salah",
18
+ "namaz",
19
+ "adhan",
20
+ "cli"
21
+ ],
22
+ "author": "Hamza Rasheed",
23
+ "license": "ISC",
24
+ "description": "A lightweight, beautiful, and interactive CLI to check Azaan times anywhere in the world.",
25
+ "dependencies": {
26
+ "@clack/prompts": "^1.0.1",
27
+ "axios": "^1.13.5",
28
+ "chalk": "^5.6.2",
29
+ "cli-table3": "^0.6.5",
30
+ "commander": "^14.0.3",
31
+ "conf": "^15.1.0",
32
+ "date-fns": "^4.1.0",
33
+ "node-notifier": "^10.0.1"
34
+ }
35
+ }
package/src/api.js ADDED
@@ -0,0 +1,38 @@
1
+ import axios from 'axios';
2
+
3
+ const ALADHAN_BASE_URL = 'http://api.aladhan.com/v1';
4
+
5
+ /**
6
+ * Fetches prayer timings for a given date.
7
+ * If date is not provided, defaults to today.
8
+ *
9
+ * @param {string} city
10
+ * @param {string} country
11
+ * @param {number} method - Calculation method
12
+ * @param {string} [date] - DD-MM-YYYY format
13
+ */
14
+ export async function fetchTimings(city, country, method = 2, date = null) {
15
+ try {
16
+ const endpoint = date
17
+ ? `${ALADHAN_BASE_URL}/timingsByCity/${date}`
18
+ : `${ALADHAN_BASE_URL}/timingsByCity`;
19
+
20
+ const params = {
21
+ city,
22
+ country,
23
+ method,
24
+ };
25
+
26
+ const response = await axios.get(endpoint, { params });
27
+ if (response.data && response.data.code === 200) {
28
+ return response.data;
29
+ } else {
30
+ throw new Error('Invalid response from Aladhan API');
31
+ }
32
+ } catch (error) {
33
+ if (error.response && error.response.data && error.response.data.data) {
34
+ throw new Error(`API Error: ${error.response.data.data}`);
35
+ }
36
+ throw new Error(`Failed to fetch timings: ${error.message}`);
37
+ }
38
+ }
package/src/cli.js ADDED
@@ -0,0 +1,161 @@
1
+ import { Command } from 'commander';
2
+ import { getConfig, setConfig, clearConfig, getDaemonPid, setDaemonPid } from './config.js';
3
+ import { runInteractiveSetup, printTimingsTable, createSpinner } from './ui.js';
4
+ import { fetchTimings } from './api.js';
5
+ import { getNextPrayer, formatCountdown, formatCountdownVerbose, formatTime12h } from './utils.js';
6
+ import chalk from 'chalk';
7
+ import notifier from 'node-notifier';
8
+ import { format } from 'date-fns';
9
+ import { fileURLToPath } from 'url';
10
+ import { dirname, join } from 'path';
11
+ import { spawn } from 'child_process';
12
+
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = dirname(__filename);
15
+
16
+ function isDaemonRunning(pid) {
17
+ if (!pid) return false;
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ } catch (e) {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ function startDaemonIfNeeded() {
27
+ const pid = getDaemonPid();
28
+ if (isDaemonRunning(pid)) return;
29
+
30
+ const daemonPath = join(__dirname, 'daemon.js');
31
+ const child = spawn(process.execPath, [daemonPath], {
32
+ detached: true,
33
+ stdio: 'ignore'
34
+ });
35
+
36
+ child.unref();
37
+ if (child.pid) setDaemonPid(child.pid);
38
+ }
39
+
40
+ export async function runCli() {
41
+ const program = new Command();
42
+
43
+ program
44
+ .name('azaan')
45
+ .description('CLI to check Azaan times anywhere in the world.')
46
+ .version('1.0.0');
47
+
48
+ program
49
+ .argument('[city]', 'City to check timings for (one-off)')
50
+ .option('-s, --status', 'Print single-line next prayer status')
51
+ .action(async (cmdCity, options) => {
52
+ let config = getConfig();
53
+
54
+ // If no config and no arguments, ask for setup unless it's a status check
55
+ if (!config.city && !cmdCity && !options.status) {
56
+ config = await runInteractiveSetup({});
57
+ setConfig(config);
58
+ }
59
+
60
+ const city = cmdCity || config.city;
61
+ const country = config.country || '';
62
+ const method = config.method || 2;
63
+
64
+ if (!city) {
65
+ console.error(chalk.red('City not configured. Run "azaan config" or provide a city parameter.'));
66
+ process.exit(1);
67
+ }
68
+
69
+ // Auto start daemon
70
+ startDaemonIfNeeded();
71
+
72
+ let s;
73
+ if (!options.status) s = createSpinner(`Fetching azaan times for ${city}...`);
74
+
75
+ try {
76
+ const dateStr = format(new Date(), 'dd-MM-yyyy');
77
+ // Actually fetch Timings by City from Aladhan wrapper
78
+ const data = await fetchTimings(city, country, method, dateStr);
79
+ let todayTimings = null;
80
+
81
+ // Find today's data (if array, pick first, usually it's single object for "timingsByCity/date")
82
+ if (data && data.data && data.data.timings) {
83
+ todayTimings = data.data.timings;
84
+ }
85
+
86
+ if (s) s.stop('Times fetched successfully.');
87
+
88
+ if (!todayTimings) {
89
+ throw new Error('Timings not found in response');
90
+ }
91
+
92
+ if (options.status) {
93
+ const next = getNextPrayer(todayTimings);
94
+ if (next) {
95
+ console.log(`Next prayer is ${next.name} in ${formatCountdownVerbose(next.diffSeconds)} at ${formatTime12h(next.time)}`);
96
+ process.exit(0);
97
+ } else {
98
+ console.log('No more prayers today.');
99
+ process.exit(0);
100
+ }
101
+ }
102
+
103
+ printTimingsTable(todayTimings, city);
104
+
105
+ } catch (err) {
106
+ if (s) s.stop('Failed to fetch data.', 1);
107
+ console.error(chalk.red('\nError: ' + err.message));
108
+ process.exit(1);
109
+ }
110
+ });
111
+
112
+ program
113
+ .command('config')
114
+ .description('Configure default settings')
115
+ .action(async () => {
116
+ const config = getConfig();
117
+ const newConfig = await runInteractiveSetup(config);
118
+ setConfig(newConfig);
119
+ });
120
+
121
+ program
122
+ .command('reset')
123
+ .description('Clear saved settings')
124
+ .action(() => {
125
+ clearConfig();
126
+ console.log(chalk.green('Configuration cleared successfully.'));
127
+ });
128
+
129
+ program
130
+ .command('daemon [action]')
131
+ .description('Manage the background notification daemon (start/stop/status)')
132
+ .action((action) => {
133
+ const pid = getDaemonPid();
134
+ if (action === 'stop') {
135
+ if (isDaemonRunning(pid)) {
136
+ try {
137
+ process.kill(pid);
138
+ } catch (e) { }
139
+ console.log(chalk.green('Daemon stopped.'));
140
+ } else {
141
+ console.log(chalk.yellow('Daemon is not running.'));
142
+ }
143
+ setDaemonPid(null);
144
+ } else if (action === 'start') {
145
+ if (isDaemonRunning(pid)) {
146
+ console.log(chalk.yellow('Daemon is already running.'));
147
+ } else {
148
+ startDaemonIfNeeded();
149
+ console.log(chalk.green('Daemon started successfully! You will receive notifications in the background.'));
150
+ }
151
+ } else {
152
+ if (isDaemonRunning(pid)) {
153
+ console.log(chalk.green(`Daemon is running in the background (PID: ${pid}).`));
154
+ } else {
155
+ console.log(chalk.yellow('Daemon is not running.'));
156
+ }
157
+ }
158
+ });
159
+
160
+ await program.parseAsync(process.argv);
161
+ }
package/src/config.js ADDED
@@ -0,0 +1,41 @@
1
+ import Conf from 'conf';
2
+
3
+ // Initialize a new config instance specific to azaan-cli
4
+ const config = new Conf({
5
+ projectName: 'azaan-cli',
6
+ defaults: {
7
+ city: null,
8
+ country: null,
9
+ method: 2, // ISNA is method 2 in Aladhan API
10
+ school: 0, // Shafi by default
11
+ daemonPid: null, // Store PID of the detached daemon
12
+ }
13
+ });
14
+
15
+ export function getConfig() {
16
+ return {
17
+ city: config.get('city'),
18
+ country: config.get('country'),
19
+ method: config.get('method'),
20
+ school: config.get('school'),
21
+ };
22
+ }
23
+
24
+ export function setConfig(newConfig) {
25
+ if (newConfig.city) config.set('city', newConfig.city);
26
+ if (newConfig.country) config.set('country', newConfig.country);
27
+ if (newConfig.method !== undefined) config.set('method', newConfig.method);
28
+ if (newConfig.school !== undefined) config.set('school', newConfig.school);
29
+ }
30
+
31
+ export function getDaemonPid() {
32
+ return config.get('daemonPid');
33
+ }
34
+
35
+ export function setDaemonPid(pid) {
36
+ config.set('daemonPid', pid);
37
+ }
38
+
39
+ export function clearConfig() {
40
+ config.clear();
41
+ }
package/src/daemon.js ADDED
@@ -0,0 +1,71 @@
1
+ import { getConfig } from './config.js';
2
+ import { fetchTimings } from './api.js';
3
+ import { getNextPrayer, formatTime12h } from './utils.js';
4
+ import notifier from 'node-notifier';
5
+ import { format } from 'date-fns';
6
+ import { fileURLToPath } from 'url';
7
+ import { dirname, join } from 'path';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+ const ICON_PATH = join(__dirname, '../assets/icon.png');
12
+
13
+ // 60 seconds interval
14
+ const INTERVAL_MS = 60 * 1000;
15
+
16
+ // Poll loop
17
+ async function runDaemon() {
18
+ const config = getConfig();
19
+ if (!config.city) return;
20
+
21
+ try {
22
+ const dateStr = format(new Date(), 'dd-MM-yyyy');
23
+ const data = await fetchTimings(config.city, config.country || '', config.method || 2, dateStr);
24
+ const timings = data?.data?.timings;
25
+
26
+ if (!timings) return;
27
+
28
+ const next = getNextPrayer(timings);
29
+ if (next) {
30
+ // Send notification exactly 10 minutes beforehand.
31
+ // Since interval is 60s, diffSeconds will be between 540 and 600 seconds.
32
+ // To ensure we report exactly once, keep track of the reported prayer.
33
+ // Since it's a daemon that could be restarted, checking the time diff is safer.
34
+ const diffMins = next.diffSeconds / 60;
35
+
36
+ // If diff is between 9 and 10 minutes
37
+ if (diffMins > 9 && diffMins <= 10) {
38
+ const minutes = Math.ceil(diffMins);
39
+
40
+ notifier.notify({
41
+ appID: 'Azaan CLI',
42
+ icon: ICON_PATH,
43
+ title: `Azaan Reminder: ${next.name}`,
44
+ message: `${next.name} is starting in ${minutes} minutes at ${formatTime12h(next.time)}!`,
45
+ sound: true,
46
+ wait: false
47
+ });
48
+ }
49
+
50
+ // If diff is exactly 0 (between 0 and 1 min)
51
+ if (diffMins > 0 && diffMins <= 1) {
52
+ notifier.notify({
53
+ appID: 'Azaan CLI',
54
+ icon: ICON_PATH,
55
+ title: `Azaan Time: ${next.name}`,
56
+ message: `It is now time for ${next.name} (${formatTime12h(next.time)}).`,
57
+ sound: true,
58
+ wait: false
59
+ });
60
+ }
61
+ }
62
+ } catch (err) {
63
+ // Fail silently in background
64
+ }
65
+ }
66
+
67
+ // Initial run
68
+ runDaemon();
69
+
70
+ // Start polling
71
+ setInterval(runDaemon, INTERVAL_MS);
package/src/ui.js ADDED
@@ -0,0 +1,122 @@
1
+ import { intro, text, select, spinner, isCancel, cancel, outro } from '@clack/prompts';
2
+ import chalk from 'chalk';
3
+ import Table from 'cli-table3';
4
+ import { formatTime12h, getNextPrayer, formatCountdownVerbose } from './utils.js';
5
+
6
+ export async function runInteractiveSetup(currentConfig = {}) {
7
+ intro(chalk.bgGreen.white(' Azaan CLI Setup '));
8
+
9
+ const city = await text({
10
+ message: 'Which city are you in?',
11
+ placeholder: 'e.g. Lahore, London, New York',
12
+ initialValue: currentConfig.city || '',
13
+ validate(value) {
14
+ if (value.length === 0) return `City is required!`;
15
+ },
16
+ });
17
+
18
+ if (isCancel(city)) {
19
+ cancel('Setup cancelled.');
20
+ process.exit(0);
21
+ }
22
+
23
+ const country = await text({
24
+ message: 'Which country?',
25
+ placeholder: 'e.g. Pakistan, UK, US',
26
+ initialValue: currentConfig.country || '',
27
+ validate(value) {
28
+ if (value.length === 0) return `Country is required!`;
29
+ },
30
+ });
31
+
32
+ if (isCancel(country)) {
33
+ cancel('Setup cancelled.');
34
+ process.exit(0);
35
+ }
36
+
37
+ const method = await select({
38
+ message: 'Calculation Method',
39
+ options: [
40
+ { value: 1, label: 'University of Islamic Sciences, Karachi' },
41
+ { value: 2, label: 'Islamic Society of North America (ISNA)' },
42
+ { value: 3, label: 'Muslim World League' },
43
+ { value: 4, label: 'Umm Al-Qura University, Makkah' },
44
+ { value: 5, label: 'Egyptian General Authority of Survey' },
45
+ { value: 8, label: 'Gulf Region' },
46
+ { value: 9, label: 'Kuwait' },
47
+ { value: 10, label: 'Qatar' },
48
+ { value: 11, label: 'Majlis Ugama Islam Singapura, Singapore' },
49
+ { value: 12, label: 'Union Organization islamic de France' },
50
+ { value: 13, label: 'Diyanet İşleri Başkanlığı, Turkey' },
51
+ { value: 14, label: 'Spiritual Administration of Muslims of Russia' }
52
+ ],
53
+ initialValue: currentConfig.method || 2,
54
+ });
55
+
56
+ if (isCancel(method)) {
57
+ cancel('Setup cancelled.');
58
+ process.exit(0);
59
+ }
60
+
61
+ outro('Setup complete! Configuration saved.');
62
+
63
+ return { city, country, method };
64
+ }
65
+
66
+ export function printTimingsTable(timingsObj, city) {
67
+ console.log(chalk.cyan(`
68
+ █████╗ ███████╗ █████╗ █████╗ ███╗ ██╗
69
+ ██╔══██╗╚══███╔╝██╔══██╗██╔══██╗████╗ ██║
70
+ ███████║ ███╔╝ ███████║███████║██╔██╗ ██║
71
+ ██╔══██║ ███╔╝ ██╔══██║██╔══██║██║╚██╗██║
72
+ ██║ ██║███████╗██║ ██║██║ ██║██║ ╚████║
73
+ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝
74
+ `));
75
+ console.log(` 🕌 ${chalk.bold('Daily Prayer Timings')}`);
76
+ console.log(` 📍 ${chalk.green.bold(city)} (${chalk.gray('Today')})\n`);
77
+
78
+ const table = new Table({
79
+ head: [chalk.bold.blue('Prayer'), chalk.bold.blue('Time')],
80
+ colWidths: [20, 20],
81
+ chars: {
82
+ 'top': '', 'top-mid': '', 'top-left': '', 'top-right': '',
83
+ 'bottom': '', 'bottom-mid': '', 'bottom-left': '', 'bottom-right': '',
84
+ 'left': ' ', 'left-mid': '', 'mid': '', 'mid-mid': '',
85
+ 'right': '', 'right-mid': '', 'middle': ' '
86
+ },
87
+ style: { 'padding-left': 0, 'padding-right': 0, head: [], border: [] }
88
+ });
89
+
90
+ const nextPrayer = getNextPrayer(timingsObj);
91
+ const prayers = ['Fajr', 'Dhuhr', 'Asr', 'Maghrib', 'Isha'];
92
+
93
+ prayers.forEach((prayer) => {
94
+ if (timingsObj[prayer]) {
95
+ // Remove timezone string like "(EST)"
96
+ const rawTime = timingsObj[prayer].split(' ')[0];
97
+ const time12h = formatTime12h(rawTime);
98
+
99
+ let pName = prayer;
100
+ let pTime = time12h;
101
+
102
+ if (nextPrayer && nextPrayer.name === prayer) {
103
+ pName = chalk.bgYellow.black(` ${prayer} (Next) `);
104
+ pTime = chalk.yellow.bold(time12h);
105
+ }
106
+
107
+ table.push([pName, pTime]);
108
+ }
109
+ });
110
+
111
+ console.log(table.toString());
112
+
113
+ if (nextPrayer) {
114
+ console.log(`\n Next prayer is ${chalk.bold.yellow(nextPrayer.name)} in ${chalk.bold.cyan(formatCountdownVerbose(nextPrayer.diffSeconds))} at ${chalk.bold.magenta(formatTime12h(nextPrayer.time))}\n`);
115
+ }
116
+ }
117
+
118
+ export function createSpinner(msg) {
119
+ const s = spinner();
120
+ s.start(msg);
121
+ return s;
122
+ }
package/src/utils.js ADDED
@@ -0,0 +1,90 @@
1
+ import { parse, format, differenceInSeconds, isAfter, isBefore } from 'date-fns';
2
+
3
+ /**
4
+ * Converts HH:mm (24-hour) format into a 12-hour AM/PM string.
5
+ */
6
+ export function formatTime12h(time24) {
7
+ const [hours, minutes] = time24.split(':');
8
+ const d = new Date();
9
+ d.setHours(parseInt(hours, 10), parseInt(minutes, 10));
10
+ return format(d, 'hh:mm a');
11
+ }
12
+
13
+ /**
14
+ * Given a list of timings from Aladhan API, find the next prayer.
15
+ * Returns { name, time, diffSeconds } or null.
16
+ */
17
+ export function getNextPrayer(timingsObj) {
18
+ const now = new Date();
19
+
20
+ // The prayers we care about
21
+ const prayers = ['Fajr', 'Dhuhr', 'Asr', 'Maghrib', 'Isha'];
22
+
23
+ let nextPrayer = null;
24
+ let minDiff = Infinity;
25
+
26
+ // Clean the timings (Aladhan sometimes appends (PKT) etc)
27
+ for (const prayer of prayers) {
28
+ if (!timingsObj[prayer]) continue;
29
+
30
+ const timeStr = timingsObj[prayer].split(' ')[0]; // removes " (EST)"
31
+ const [hours, minutes] = timeStr.split(':');
32
+
33
+ const prayerTime = new Date();
34
+ prayerTime.setHours(parseInt(hours, 10), parseInt(minutes, 10), 0, 0);
35
+
36
+ if (isAfter(prayerTime, now)) {
37
+ const diffSecs = differenceInSeconds(prayerTime, now);
38
+ if (diffSecs < minDiff) {
39
+ minDiff = diffSecs;
40
+ nextPrayer = {
41
+ name: prayer,
42
+ time: timeStr,
43
+ diffSeconds: diffSecs,
44
+ dateObj: prayerTime
45
+ };
46
+ }
47
+ }
48
+ }
49
+
50
+ // If no next prayer today, the next is Fajr tomorrow
51
+ // (We'll handle tomorrow Fajr logic loosely by just saying "Tomorrow's Fajr" or leaving null)
52
+ return nextPrayer;
53
+ }
54
+
55
+ /**
56
+ * Format seconds into HH:MM:SS
57
+ */
58
+ export function formatCountdown(totalSeconds) {
59
+ const hours = Math.floor(totalSeconds / 3600);
60
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
61
+ const seconds = totalSeconds % 60;
62
+
63
+ const pad = (num) => String(num).padStart(2, '0');
64
+
65
+ if (hours > 0) {
66
+ return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
67
+ }
68
+ return `${pad(minutes)}:${pad(seconds)}`;
69
+ }
70
+
71
+ /**
72
+ * Format seconds into verbose text (e.g. 45 min and 12 second)
73
+ */
74
+ export function formatCountdownVerbose(totalSeconds) {
75
+ const hours = Math.floor(totalSeconds / 3600);
76
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
77
+ const seconds = totalSeconds % 60;
78
+
79
+ let parts = [];
80
+ if (hours > 0) parts.push(`${hours} hr${hours > 1 ? 's' : ''}`);
81
+ if (minutes > 0) parts.push(`${minutes} min${minutes > 1 ? 's' : ''}`);
82
+ if (seconds > 0) parts.push(`${seconds} second${seconds > 1 ? 's' : ''}`);
83
+
84
+ if (parts.length === 0) return 'now';
85
+ if (parts.length === 1) return parts[0];
86
+ if (parts.length === 2) return `${parts[0]} and ${parts[1]}`;
87
+
88
+ const last = parts.pop();
89
+ return `${parts.join(', ')} and ${last}`;
90
+ }