diff --git a/locales/ar/common.json b/locales/ar/common.json index f9000803e7..04ef799a66 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -15,8 +15,8 @@ }, "quran-foundation-link": { "action": "التالي", - "description": "استخدم تسجيل الدخول الخاص بك إلى موقع Quran.com للوصول إلى موقع QuranReflect.com - مجتمع عالمي مزدهر يشارك في التفكير القرآني.", - "title": "استخدم حسابك عبر تطبيقات Quran.Foundation." + "description": "استخدم تسجيل الدخول الخاص بك إلى موقع قرآن دوت كوم (Quran.com) للوصول إلى موقع تأملات قرآنية (QuranReflect.com) - مجتمع عالمي مزدهر يشارك في التفكير القرآني.", + "title": "استخدم حسابك عبر تطبيقات مؤسسة القرآن!" }, "quran-growth-journey": { "action": "التالي", @@ -181,6 +181,7 @@ "help": "مساعدة", "hizb": "حزب", "home": "الصفحة الرئيسة", + "hours": "ساعات", "inline": "في النسق", "input": { "clear": "مسح" @@ -196,6 +197,7 @@ "meccan": "مكية", "medinan": "مدنية", "menu": "القائمة", + "minutes": "دقائق", "mobile-apps": "تطبيقات المحمول", "mode": "الوضع", "more": "المزيد", @@ -259,6 +261,7 @@ "switch-mode": "تحول إلى البحث المفصل", "title": "بحث" }, + "seconds": "ثواني", "settings": { "no-tafsir-selected": "لم يتم اختيار تفسير", "no-translation-selected": "لم يتم اختيار ترجمة", diff --git a/locales/ar/reading-goal.json b/locales/ar/reading-goal.json index 8e0aec69ad..06a83a23e0 100644 --- a/locales/ar/reading-goal.json +++ b/locales/ar/reading-goal.json @@ -86,9 +86,9 @@ "time-goal": "اقرأ {{time}} من القرآن" }, "reading-goal": "هدف القراءة", - "reading-goal-description": "هل تجد صعوبة في الحفاظ على التوافق مع أهدافك في قراءة القرآن؟ رحلة نمو القرآن هي ميزة ديناميكية تم تطويرها لمساعدتك على البقاء متسقًا في رحلتك مع القرآن. سواء كنت تهدف إلى قراءة 10 دقائق في اليوم ، أو إكمال جزء في شهر ، أو إنهاء القرآن بالكامل في عام ، وما إلى ذلك ، يمكن أن يساعدك تطبيق Quran.com الآن في تحديد هدف مخصص وتتبع خطوط القراءة اليومية ، بينما تتكيف مع تقدمك. إنه مجاني تمامًا للاستخدام ونأمل أن يساعدك على البقاء متحمسًا للوصول إلى هدفك!", + "reading-goal-description": "هل تجد صعوبة في الحفاظ على التوافق مع أهدافك في قراءة القرآن؟ رحلة نمو القرآن هي ميزة ديناميكية تم تطويرها لمساعدتك على البقاء متسقًا في رحلتك مع القرآن. سواء كنت تهدف إلى قراءة 10 دقائق في اليوم، أو إكمال جزء في شهر، أو إنهاء القرآن بالكامل في عام، وما إلى ذلك، يمكن أن يساعدك تطبيق قرآن دوت كوم (Quran.com) الآن في تحديد هدف مخصص وتتبع خطوط القراءة اليومية، بينما تتكيف مع تقدمك. إنه مجاني تمامًا للاستخدام ونأمل أن يساعدك على البقاء متحمسًا للوصول إلى هدفك!", "reading-goal-label": "أنت على", - "reading-goal-title": "تقديم نمو الرحلة القرآنية", + "reading-goal-title": "تقديم رحلة الارتقاء القرآنية", "recommended": "مُستَحسَن", "remaining": "المتبقي لهذا اليوم", "remaining-days": { diff --git a/locales/ar/reading-progress.json b/locales/ar/reading-progress.json index 1f761e2190..b79a20aa70 100644 --- a/locales/ar/reading-progress.json +++ b/locales/ar/reading-progress.json @@ -1,4 +1,6 @@ { + "add": "أضف", + "add-data-success": "تمت إضافة بيانات القراءة الخاصة بك بنجاح.", "delete-goal": { "action": "احذف الهدف", "confirmation": { @@ -19,8 +21,10 @@ "history": "سجل القراءة", "history-for": "سجل القراءة لـ {{date}}", "manage-goal": "إدارة هدفك في القراءة", + "manually-add": "إضافة القراءات يدويا", "no-reading-history-for": "لا يوجد سجل قراءة لـ {{date}}", "reading-progress-header": "تقدمك", "reading-progress-streak": "الاستمرارية", + "reading-time": "وقت القراءة", "you-read": "أنت تقرأ:" } \ No newline at end of file diff --git a/locales/bn/common.json b/locales/bn/common.json index 935eabf2e6..2de9febc0d 100644 --- a/locales/bn/common.json +++ b/locales/bn/common.json @@ -181,6 +181,7 @@ "help": "সাহায্য", "hizb": "হিযব", "home": "বাড়ি", + "hours": "ঘন্টার", "inline": "সঙ্গতিপূর্ণভাবে", "input": { "clear": "পরিষ্কার" @@ -196,6 +197,7 @@ "meccan": "মক্কান", "medinan": "মেদিনান", "menu": "তালিকা", + "minutes": "মিনিট", "mobile-apps": "মোবাইল অ্যাপস", "mode": "মোড", "more": "আরও", @@ -259,6 +261,7 @@ "switch-mode": "উন্নত অনুসন্ধানে স্যুইচ করুন", "title": "অনুসন্ধান করুন" }, + "seconds": "সেকেন্ড", "settings": { "no-tafsir-selected": "কোন তাফসির নির্বাচিত নয়", "no-translation-selected": "কোনো অনুবাদ নির্বাচন করা হয়নি", diff --git a/locales/bn/reading-progress.json b/locales/bn/reading-progress.json index 9ff0c0b2bd..37e51f6aaf 100644 --- a/locales/bn/reading-progress.json +++ b/locales/bn/reading-progress.json @@ -1,4 +1,6 @@ { + "add": "যোগ করুন", + "add-data-success": "আপনার পড়ার ডেটা সফলভাবে যোগ করা হয়েছে।", "delete-goal": { "action": "লক্ষ্য মুছুন", "confirmation": { @@ -19,8 +21,10 @@ "history": "ইতিহাস পড়া", "history-for": "{{date}} এর ইতিহাস পড়ার", "manage-goal": "আপনার পড়ার লক্ষ্য পরিচালনা করুন", + "manually-add": "ম্যানুয়ালি রিডিং যোগ করুন", "no-reading-history-for": "{{date}} এর জন্য কোনো পড়ার ইতিহাস নেই", "reading-progress-header": "আপনার অগ্রগতি", "reading-progress-streak": "স্ট্রিক", + "reading-time": "পড়ার সময়", "you-read": "আপনি পড়েছেন:" } \ No newline at end of file diff --git a/locales/en/common.json b/locales/en/common.json index 79a77a9103..b755a4fbd3 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -181,6 +181,7 @@ "help": "Help", "hizb": "Hizb", "home": "Home", + "hours": "Hours", "inline": "In-line", "input": { "clear": "Clear" @@ -196,6 +197,7 @@ "meccan": "Meccan", "medinan": "Medinan", "menu": "Menu", + "minutes": "Minutes", "mobile-apps": "Mobile Apps", "mode": "Mode", "more": "More", @@ -259,6 +261,7 @@ "switch-mode": "Switch to Advanced Search", "title": "Search" }, + "seconds": "Seconds", "settings": { "no-tafsir-selected": "No tafsir selected", "no-translation-selected": "No translation selected", diff --git a/locales/en/reading-progress.json b/locales/en/reading-progress.json index 9fc2b56c94..f615e89d66 100644 --- a/locales/en/reading-progress.json +++ b/locales/en/reading-progress.json @@ -1,4 +1,6 @@ { + "add": "Add", + "add-data-success": "Your reading data has been added successfully.", "delete-goal": { "action": "Delete Goal", "confirmation": { @@ -19,8 +21,10 @@ "history": "Reading History", "history-for": "Reading history for {{date}}", "manage-goal": "Manage your reading goal", + "manually-add": "Manually add readings", "no-reading-history-for": "No reading history for {{date}}", "reading-progress-header": "Your Progress", "reading-progress-streak": "Streak", + "reading-time": "Reading time", "you-read": "You read:" } \ No newline at end of file diff --git a/locales/fa/common.json b/locales/fa/common.json index 975a6ccb30..9eab9c0161 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -181,6 +181,7 @@ "help": "کمک", "hizb": "حزب", "home": "صفحه اصلی", + "hours": "ساعت ها", "inline": "در خط", "input": { "clear": "پاک کردن" @@ -196,6 +197,7 @@ "meccan": "مکه", "medinan": "مدینه", "menu": "منو", + "minutes": "دقایق", "mobile-apps": "برنامه های موبایل", "mode": "حالت", "more": "بیشتر", @@ -259,6 +261,7 @@ "switch-mode": "به جستجوی پیشرفته بروید", "title": "جستجو کردن" }, + "seconds": "ثانیه ها", "settings": { "no-tafsir-selected": "هیچ تفسیری انتخاب نشده است", "no-translation-selected": "ترجمه ای انتخاب نشده است", diff --git a/locales/fa/reading-progress.json b/locales/fa/reading-progress.json index 75f1ff89b7..93b9961fa3 100644 --- a/locales/fa/reading-progress.json +++ b/locales/fa/reading-progress.json @@ -1,4 +1,6 @@ { + "add": "اضافه کردن", + "add-data-success": "اطلاعات خواندن شما با موفقیت اضافه شد.", "delete-goal": { "action": "حذف هدف", "confirmation": { @@ -19,8 +21,10 @@ "history": "خواندن تاریخ", "history-for": "خواندن تاریخچه برای {{date}}", "manage-goal": "هدف مطالعه خود را مدیریت کنید", + "manually-add": "خوانش ها را به صورت دستی اضافه کنید", "no-reading-history-for": "بدون سابقه خواندن برای {{date}}", "reading-progress-header": "پیشرفت شما", "reading-progress-streak": "خط", + "reading-time": "زمان خواندن", "you-read": "تو می خوانی:" } \ No newline at end of file diff --git a/locales/fr/common.json b/locales/fr/common.json index c9b228a743..3415520028 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -181,6 +181,7 @@ "help": "Aider", "hizb": "Hizb", "home": "Accueil", + "hours": "Heures", "inline": "En ligne", "input": { "clear": "Dégager" @@ -196,6 +197,7 @@ "meccan": "La Mecque", "medinan": "Médinan", "menu": "Menu", + "minutes": "Minutes", "mobile-apps": "Application mobile", "mode": "Mode", "more": "Suite", @@ -259,6 +261,7 @@ "switch-mode": "Passer à la recherche avancée", "title": "Chercher" }, + "seconds": "Secondes", "settings": { "no-tafsir-selected": "Aucun tafsir sélectionné", "no-translation-selected": "Aucune traduction sélectionnée", diff --git a/locales/fr/reading-progress.json b/locales/fr/reading-progress.json index 8e4522487c..d527bd487f 100644 --- a/locales/fr/reading-progress.json +++ b/locales/fr/reading-progress.json @@ -1,4 +1,6 @@ { + "add": "Ajouter", + "add-data-success": "Vos données de lecture ont été ajoutées avec succès.", "delete-goal": { "action": "Supprimer l'objectif", "confirmation": { @@ -19,8 +21,10 @@ "history": "Lecture de l'histoire", "history-for": "Historique de lecture pour {{date}}", "manage-goal": "Gérez votre objectif de lecture", + "manually-add": "Ajouter manuellement des lectures", "no-reading-history-for": "Aucun historique de lecture pour {{date}}", "reading-progress-header": "Votre progression", "reading-progress-streak": "Traînée", + "reading-time": "Temps de lecture", "you-read": "Tu lis:" } \ No newline at end of file diff --git a/locales/id/common.json b/locales/id/common.json index 40d0e6ec5e..0c4aa9d3a2 100644 --- a/locales/id/common.json +++ b/locales/id/common.json @@ -181,6 +181,7 @@ "help": "Bantuan", "hizb": "Hizb", "home": "Halaman Utama", + "hours": "Jam", "inline": "Di barisan", "input": { "clear": "bersihkan" @@ -196,6 +197,7 @@ "meccan": "Mekah", "medinan": "Madinah", "menu": "Menu", + "minutes": "Menit", "mobile-apps": "Aplikasi", "mode": "Mode", "more": "Lagi", @@ -259,6 +261,7 @@ "switch-mode": "Beralih ke Pencarian Lanjutan", "title": "Mencari" }, + "seconds": "Detik", "settings": { "no-tafsir-selected": "Tidak ada tafsir yang dipilih", "no-translation-selected": "Tidak ada terjemahan yang dipilih", diff --git a/locales/id/reading-progress.json b/locales/id/reading-progress.json index 0b917f9d1d..48376ea819 100644 --- a/locales/id/reading-progress.json +++ b/locales/id/reading-progress.json @@ -1,4 +1,6 @@ { + "add": "Menambahkan", + "add-data-success": "Data bacaan Anda telah berhasil ditambahkan.", "delete-goal": { "action": "Hapus Sasaran", "confirmation": { @@ -19,8 +21,10 @@ "history": "Membaca Sejarah", "history-for": "Riwayat membaca untuk {{date}}", "manage-goal": "Kelola tujuan membaca Anda", + "manually-add": "Tambahkan bacaan secara manual", "no-reading-history-for": "Tidak ada riwayat membaca untuk {{date}}", "reading-progress-header": "Kemajuan Anda", "reading-progress-streak": "Garis", + "reading-time": "Waktu membaca", "you-read": "Bacalah:" } \ No newline at end of file diff --git a/locales/it/common.json b/locales/it/common.json index 0eba350ba9..f6df7e9c49 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -181,6 +181,7 @@ "help": "Aiuto", "hizb": "Hizb", "home": "Casa", + "hours": "Ore", "inline": "In linea", "input": { "clear": "Chiaro" @@ -196,6 +197,7 @@ "meccan": "Meccanico", "medinan": "Medinan", "menu": "Menù", + "minutes": "Minuti", "mobile-apps": "App mobili", "mode": "Modalità", "more": "Di più", @@ -259,6 +261,7 @@ "switch-mode": "Passa alla ricerca avanzata", "title": "Ricerca" }, + "seconds": "Secondi", "settings": { "no-tafsir-selected": "Nessun tafsir selezionato", "no-translation-selected": "Nessuna traduzione selezionata", diff --git a/locales/it/reading-progress.json b/locales/it/reading-progress.json index a0171553f9..b82f4cf3e8 100644 --- a/locales/it/reading-progress.json +++ b/locales/it/reading-progress.json @@ -1,4 +1,6 @@ { + "add": "Aggiungere", + "add-data-success": "I tuoi dati di lettura sono stati aggiunti con successo.", "delete-goal": { "action": "Elimina obiettivo", "confirmation": { @@ -19,8 +21,10 @@ "history": "Leggere la Storia", "history-for": "Cronologia di lettura per {{date}}", "manage-goal": "Gestisci il tuo obiettivo di lettura", + "manually-add": "Aggiungi manualmente le letture", "no-reading-history-for": "Nessuna cronologia di lettura per {{date}}", "reading-progress-header": "I tuoi progressi", "reading-progress-streak": "Strisciante", + "reading-time": "Momento della lettura", "you-read": "Tu leggi:" } \ No newline at end of file diff --git a/locales/ms/common.json b/locales/ms/common.json index 4c6150e55d..ac2575df17 100644 --- a/locales/ms/common.json +++ b/locales/ms/common.json @@ -181,6 +181,7 @@ "help": "Bantuan", "hizb": "Hizb", "home": "Halaman Utama", + "hours": "Jam", "inline": "Dalam barisan", "input": { "clear": "Hilangkan" @@ -196,6 +197,7 @@ "meccan": "Mekah", "medinan": "Madinah", "menu": "Menu", + "minutes": "minit", "mobile-apps": "Aplikasi Fon", "mode": "Mod", "more": "Lagi", @@ -259,6 +261,7 @@ "switch-mode": "Tukar kepada Carian Terperinci", "title": "Cari" }, + "seconds": "Detik", "settings": { "no-tafsir-selected": "Tiada tafsir yang dipilih", "no-translation-selected": "Tiada terjemahan yang dipilih", diff --git a/locales/ms/reading-progress.json b/locales/ms/reading-progress.json index 23cb13a332..a2ae419806 100644 --- a/locales/ms/reading-progress.json +++ b/locales/ms/reading-progress.json @@ -1,4 +1,6 @@ { + "add": "Tambah", + "add-data-success": "Data bacaan anda telah berjaya ditambahkan.", "delete-goal": { "action": "Padamkan Matlamat", "confirmation": { @@ -19,8 +21,10 @@ "history": "Membaca Sejarah", "history-for": "Sejarah membaca untuk {{date}}", "manage-goal": "Urus matlamat membaca anda", + "manually-add": "Tambah bacaan secara manual", "no-reading-history-for": "Tiada sejarah bacaan untuk {{date}}", "reading-progress-header": "Kemajuan Anda", "reading-progress-streak": "coretan", + "reading-time": "Waktu membaca", "you-read": "Anda baca:" } \ No newline at end of file diff --git a/locales/nl/common.json b/locales/nl/common.json index e69cc10ba8..a01c55d33a 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -181,6 +181,7 @@ "help": "Helpen", "hizb": "Hizb", "home": "Huis", + "hours": "Uur", "inline": "In lijn", "input": { "clear": "Duidelijk" @@ -196,6 +197,7 @@ "meccan": "Mekkaans", "medinan": "Medina", "menu": "Menu", + "minutes": "Minuten", "mobile-apps": "Mobiele apps", "mode": "Modus", "more": "Meer", @@ -259,6 +261,7 @@ "switch-mode": "Overschakelen naar Geavanceerd zoeken", "title": "Zoeken" }, + "seconds": "Seconden", "settings": { "no-tafsir-selected": "Geen tafsir geselecteerd", "no-translation-selected": "Geen vertaling geselecteerd", diff --git a/locales/nl/reading-progress.json b/locales/nl/reading-progress.json index 8de11ea4a9..6095def775 100644 --- a/locales/nl/reading-progress.json +++ b/locales/nl/reading-progress.json @@ -1,4 +1,6 @@ { + "add": "Toevoegen", + "add-data-success": "Uw leesgegevens zijn succesvol toegevoegd.", "delete-goal": { "action": "Doel verwijderen", "confirmation": { @@ -19,8 +21,10 @@ "history": "Geschiedenis lezen", "history-for": "Leesgeschiedenis voor {{date}}", "manage-goal": "Beheer uw leesdoel", + "manually-add": "Handmatig metingen toevoegen", "no-reading-history-for": "Geen leesgeschiedenis voor {{date}}", "reading-progress-header": "Jouw vooruitgang", "reading-progress-streak": "Streep", + "reading-time": "Leestijd", "you-read": "Jij leest:" } \ No newline at end of file diff --git a/locales/pt/common.json b/locales/pt/common.json index 73d4d43991..4885a8ca57 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -181,6 +181,7 @@ "help": "Ajuda", "hizb": "Hizb", "home": "Casa", + "hours": "Horas", "inline": "Em linha", "input": { "clear": "Claro" @@ -196,6 +197,7 @@ "meccan": "Meca", "medinan": "Medina", "menu": "Cardápio", + "minutes": "Minutos", "mobile-apps": "Aplicativos móveis", "mode": "Modo", "more": "Mais", @@ -259,6 +261,7 @@ "switch-mode": "Mudar para pesquisa avançada", "title": "Procurar" }, + "seconds": "Segundos", "settings": { "no-tafsir-selected": "Nenhum tafsir selecionado", "no-translation-selected": "Nenhuma tradução selecionada", diff --git a/locales/pt/reading-progress.json b/locales/pt/reading-progress.json index 83bfede791..67d2990ac3 100644 --- a/locales/pt/reading-progress.json +++ b/locales/pt/reading-progress.json @@ -1,4 +1,6 @@ { + "add": "Adicionar", + "add-data-success": "Seus dados de leitura foram adicionados com sucesso.", "delete-goal": { "action": "Excluir meta", "confirmation": { @@ -19,8 +21,10 @@ "history": "História de leitura", "history-for": "Lendo o histórico de {{date}}", "manage-goal": "Gerencie sua meta de leitura", + "manually-add": "Adicionar leituras manualmente", "no-reading-history-for": "Sem histórico de leitura para {{date}}", "reading-progress-header": "Seu progresso", "reading-progress-streak": "Onda", + "reading-time": "Tempo de leitura", "you-read": "Você lê:" } \ No newline at end of file diff --git a/locales/ru/common.json b/locales/ru/common.json index 4b5d3882f8..de98fd696d 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -181,6 +181,7 @@ "help": "Помощь", "hizb": "Хизб", "home": "Дом", + "hours": "Часы", "inline": "В соответствии", "input": { "clear": "Прозрачный" @@ -196,6 +197,7 @@ "meccan": "мекканец", "medinan": "Медиан", "menu": "Меню", + "minutes": "Минуты", "mobile-apps": "Мобильные приложения", "mode": "Режим", "more": "Более", @@ -259,6 +261,7 @@ "switch-mode": "Перейти к расширенному поиску", "title": "Поиск" }, + "seconds": "Секунды", "settings": { "no-tafsir-selected": "Тафсир не выбран", "no-translation-selected": "Перевод не выбран", diff --git a/locales/ru/reading-progress.json b/locales/ru/reading-progress.json index 60d4b6e0ad..5f63a797ef 100644 --- a/locales/ru/reading-progress.json +++ b/locales/ru/reading-progress.json @@ -1,4 +1,6 @@ { + "add": "Добавлять", + "add-data-success": "Ваши данные о чтении были успешно добавлены.", "delete-goal": { "action": "Удалить цель", "confirmation": { @@ -19,8 +21,10 @@ "history": "Чтение истории", "history-for": "Чтение истории за {{date}}", "manage-goal": "Управляйте своей целью чтения", + "manually-add": "Добавление показаний вручную", "no-reading-history-for": "Нет истории чтения за {{date}}", "reading-progress-header": "Ваш прогресс", "reading-progress-streak": "Полоса", + "reading-time": "Время чтения", "you-read": "Ты читаешь:" } \ No newline at end of file diff --git a/locales/sq/common.json b/locales/sq/common.json index ae9e51fb6b..66e2d74908 100644 --- a/locales/sq/common.json +++ b/locales/sq/common.json @@ -181,6 +181,7 @@ "help": "Ndihmë", "hizb": "Hizb", "home": "Shtëpi", + "hours": "orët", "inline": "Ne rresht", "input": { "clear": "Qartë" @@ -196,6 +197,7 @@ "meccan": "mekas", "medinan": "Medinas", "menu": "Menu", + "minutes": "Minutat", "mobile-apps": "Aplikacionet celulare", "mode": "Modaliteti", "more": "Më shumë", @@ -259,6 +261,7 @@ "switch-mode": "Kalo te Kërkimi i Avancuar", "title": "Kërko" }, + "seconds": "Sekonda", "settings": { "no-tafsir-selected": "Nuk është zgjedhur asnjë tefsir", "no-translation-selected": "Nuk u zgjodh asnjë përkthim", diff --git a/locales/sq/reading-progress.json b/locales/sq/reading-progress.json index 2db4064397..0f46c82102 100644 --- a/locales/sq/reading-progress.json +++ b/locales/sq/reading-progress.json @@ -1,4 +1,6 @@ { + "add": "Shto", + "add-data-success": "Të dhënat tuaja të leximit janë shtuar me sukses.", "delete-goal": { "action": "Fshi objektivin", "confirmation": { @@ -19,8 +21,10 @@ "history": "Leximi i Historisë", "history-for": "Leximi i historisë për {{date}}", "manage-goal": "Menaxhoni qëllimin tuaj të leximit", + "manually-add": "Shtoni manualisht leximet", "no-reading-history-for": "Nuk ka histori leximi për {{date}}", "reading-progress-header": "Përparimi juaj", "reading-progress-streak": "brez", + "reading-time": "Koha e leximit", "you-read": "Ti lexon:" } \ No newline at end of file diff --git a/locales/th/common.json b/locales/th/common.json index d7602a5f04..bc4753c8d4 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -181,6 +181,7 @@ "help": "ช่วยเหลือ", "hizb": "ฮิซบ์", "home": "หน้าหลัก", + "hours": "ชั่วโมง", "inline": "คำแปลแบบคำต่อคำในบรรทัด", "input": { "clear": "ล้าง" @@ -196,6 +197,7 @@ "meccan": "มักกียะห์", "medinan": "มะดะนียะห์", "menu": "เมนู", + "minutes": "นาที", "mobile-apps": "แอพลิเคชัน", "mode": "โหมด", "more": "เพิ่มเติม", @@ -259,6 +261,7 @@ "switch-mode": "เปลี่ยนไปใช้การค้นหาขั้นสูง", "title": "ค้นหา" }, + "seconds": "วินาที", "settings": { "no-tafsir-selected": "ไม่มีตัฟซีรที่เลือกไว้", "no-translation-selected": "ไม่ได้เลือกคำแปลไว้", diff --git a/locales/th/reading-progress.json b/locales/th/reading-progress.json index d1d54f84e6..f8f1806279 100644 --- a/locales/th/reading-progress.json +++ b/locales/th/reading-progress.json @@ -1,4 +1,6 @@ { + "add": "เพิ่ม", + "add-data-success": "เพิ่มข้อมูลการอ่านของคุณสำเร็จแล้ว", "delete-goal": { "action": "ลบเป้าหมาย", "confirmation": { @@ -19,8 +21,10 @@ "history": "ประวัติการอ่าน", "history-for": "ประวัติการอ่านสำหรับ {{date}}", "manage-goal": "จัดการเป้าหมายการอ่านของคุณ", + "manually-add": "เพิ่มการอ่านด้วยตนเอง", "no-reading-history-for": "ไม่มีประวัติการอ่านสำหรับ {{date}}", "reading-progress-header": "ความก้าวหน้าของคุณ", "reading-progress-streak": "ช่วงเวลา", + "reading-time": "เวลาอ่านหนังสือ", "you-read": "คุณกำลังอ่าน:" } \ No newline at end of file diff --git a/locales/tr/common.json b/locales/tr/common.json index 8d574fb788..1f64f0f987 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -181,6 +181,7 @@ "help": "Yardım", "hizb": "Hizb", "home": "Anasayfa", + "hours": "Saat", "inline": "Çizgide", "input": { "clear": "Açık" @@ -196,6 +197,7 @@ "meccan": "Mekkeli", "medinan": "Medine", "menu": "Menü", + "minutes": "dakika", "mobile-apps": "Mobil Uygulamalar", "mode": "Mod", "more": "Daha fazla", @@ -259,6 +261,7 @@ "switch-mode": "Gelişmiş Aramaya Geç", "title": "Ara" }, + "seconds": "Saniye", "settings": { "no-tafsir-selected": "Tefsir seçilmedi", "no-translation-selected": "Çeviri seçilmedi", diff --git a/locales/tr/reading-progress.json b/locales/tr/reading-progress.json index 1c6d4b37b5..0f26dd3ab6 100644 --- a/locales/tr/reading-progress.json +++ b/locales/tr/reading-progress.json @@ -1,4 +1,6 @@ { + "add": "Eklemek", + "add-data-success": "Okuma verileriniz başarıyla eklendi.", "delete-goal": { "action": "Hedefi Sil", "confirmation": { @@ -19,8 +21,10 @@ "history": "Okuma Tarihi", "history-for": "{{date}} için okuma geçmişi", "manage-goal": "Okuma hedefinizi yönetin", + "manually-add": "Okumaları manuel olarak ekleme", "no-reading-history-for": "{{date}} için okuma geçmişi yok", "reading-progress-header": "Senin ilerlemen", "reading-progress-streak": "Rüzgâr gibi geçmek", + "reading-time": "Okuma zamanı", "you-read": "Sen okumak:" } \ No newline at end of file diff --git a/locales/ur/common.json b/locales/ur/common.json index 7c24020446..da075c0d27 100644 --- a/locales/ur/common.json +++ b/locales/ur/common.json @@ -181,6 +181,7 @@ "help": "مدد", "hizb": "حزب", "home": "گھر", + "hours": "گھنٹے", "inline": "ترتیب سے", "input": { "clear": "صاف" @@ -196,6 +197,7 @@ "meccan": "مکہ", "medinan": "مدینہ", "menu": "مینو", + "minutes": "منٹس", "mobile-apps": "موبائل ایپس", "mode": "موڈ", "more": "مزید", @@ -259,6 +261,7 @@ "switch-mode": "اعلی درجے کی تلاش پر جائیں۔", "title": "تلاش کریں۔" }, + "seconds": "سیکنڈز", "settings": { "no-tafsir-selected": "کوئی تفسیر منتخب نہیں ہے۔", "no-translation-selected": "کوئی ترجمہ منتخب نہیں کیا گیا۔", diff --git a/locales/ur/reading-progress.json b/locales/ur/reading-progress.json index dc214b501d..be47a31138 100644 --- a/locales/ur/reading-progress.json +++ b/locales/ur/reading-progress.json @@ -1,4 +1,6 @@ { + "add": "شامل کریں۔", + "add-data-success": "آپ کا پڑھنے کا ڈیٹا کامیابی کے ساتھ شامل کر دیا گیا ہے۔", "delete-goal": { "action": "گول کو حذف کریں۔", "confirmation": { @@ -19,8 +21,10 @@ "history": "تاریخ پڑھنا", "history-for": "{{date}} کی تاریخ پڑھنا", "manage-goal": "اپنے پڑھنے کے مقصد کا نظم کریں۔", + "manually-add": "دستی طور پر ریڈنگز شامل کریں۔", "no-reading-history-for": "{{date}} کے لیے پڑھنے کی کوئی سرگزشت نہیں", "reading-progress-header": "آپ کی پیشرفت", "reading-progress-streak": "اسٹریک", + "reading-time": "پڑھنے کا وقت", "you-read": "آپ پڑھتے ہیں:" } \ No newline at end of file diff --git a/locales/zh/common.json b/locales/zh/common.json index 031c44cd91..6a8648ecbb 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -181,6 +181,7 @@ "help": "帮助", "hizb": "希兹布", "home": "首页", + "hours": "小时", "inline": "排队", "input": { "clear": "清除" @@ -196,6 +197,7 @@ "meccan": "麦加", "medinan": "麦地那", "menu": "菜单", + "minutes": "分钟", "mobile-apps": "手机App", "mode": "模式", "more": "更多的", @@ -259,6 +261,7 @@ "switch-mode": "切换到高级搜索", "title": "搜索" }, + "seconds": "秒数", "settings": { "no-tafsir-selected": "尚未选择经注", "no-translation-selected": "尚未选择译文", diff --git a/locales/zh/reading-progress.json b/locales/zh/reading-progress.json index fb2afb0996..4bd376f9e9 100644 --- a/locales/zh/reading-progress.json +++ b/locales/zh/reading-progress.json @@ -1,4 +1,6 @@ { + "add": "添加", + "add-data-success": "您的阅读数据已成功添加。", "delete-goal": { "action": "删除目标", "confirmation": { @@ -19,8 +21,10 @@ "history": "阅读历史", "history-for": "{{date}}的阅读历史", "manage-goal": "管理你的阅读目标", + "manually-add": "手动添加读数", "no-reading-history-for": "{{date}}没有阅读记录", "reading-progress-header": "你的进步", "reading-progress-streak": "条纹", + "reading-time": "阅读时间", "you-read": "你读:" } \ No newline at end of file diff --git a/src/components/FormBuilder/buildTranslatedErrorMessageByErrorId.ts b/src/components/FormBuilder/buildTranslatedErrorMessageByErrorId.ts index 6c9f8aa41c..81d7f97858 100644 --- a/src/components/FormBuilder/buildTranslatedErrorMessageByErrorId.ts +++ b/src/components/FormBuilder/buildTranslatedErrorMessageByErrorId.ts @@ -11,9 +11,9 @@ const buildTranslatedErrorMessageByErrorId = ( t: Translate, ) => { if (Object.values(ErrorMessageId).includes(errorId)) { - return t(`validation.${errorId}`, { field: capitalize(fieldName) }); + return t(`common:validation.${errorId}`, { field: capitalize(fieldName) }); } - return t(`validation.${DEFAULT_ERROR_ID}`, { field: capitalize(fieldName) }); + return t(`common:validation.${DEFAULT_ERROR_ID}`, { field: capitalize(fieldName) }); }; export default buildTranslatedErrorMessageByErrorId; diff --git a/src/components/ReadingGoal/ReadingGoalAmount/VerseRangesList.module.scss b/src/components/ReadingGoal/ReadingGoalAmount/VerseRangesList.module.scss new file mode 100644 index 0000000000..5b46ba4502 --- /dev/null +++ b/src/components/ReadingGoal/ReadingGoalAmount/VerseRangesList.module.scss @@ -0,0 +1,5 @@ +.rowContainer { + display: flex; + align-items: center; + justify-content: space-between; +} diff --git a/src/components/ReadingGoal/ReadingGoalAmount/VerseRangesList.tsx b/src/components/ReadingGoal/ReadingGoalAmount/VerseRangesList.tsx new file mode 100644 index 0000000000..2ca397986c --- /dev/null +++ b/src/components/ReadingGoal/ReadingGoalAmount/VerseRangesList.tsx @@ -0,0 +1,112 @@ +import { useContext } from 'react'; + +import useTranslation from 'next-translate/useTranslation'; + +import styles from './VerseRangesList.module.scss'; + +import DataContext from '@/contexts/DataContext'; +import Button, { ButtonShape, ButtonSize, ButtonVariant } from '@/dls/Button/Button'; +import Link, { LinkVariant } from '@/dls/Link/Link'; +import CloseIcon from '@/icons/close.svg'; +import { RangeItemDirection } from '@/types/Range'; +import { getChapterData } from '@/utils/chapter'; +import { logButtonClick } from '@/utils/eventLogger'; +import { toLocalizedNumber } from '@/utils/locale'; +import { getChapterWithStartingVerseUrl } from '@/utils/navigation'; +import { parseVerseRange } from '@/utils/verseKeys'; + +interface VerseRangesListProps { + ranges: string[]; + onVerseClick?: (position: RangeItemDirection, verseKey: string) => void; + allowClearingRanges?: boolean; + setRanges?: (ranges: string[]) => void; +} + +const VerseRangesList = ({ + ranges, + onVerseClick, + allowClearingRanges, + setRanges, +}: VerseRangesListProps) => { + const { t, lang } = useTranslation('reading-goal'); + const chaptersData = useContext(DataContext); + + const handleVerseClick = (position: RangeItemDirection, verseKey: string) => { + if (!onVerseClick) return; + + onVerseClick(position, verseKey); + }; + + const handleRangeDeleteClick = (toBeRemovedRange: string) => { + logButtonClick('add_reading_range_remove', { + range: toBeRemovedRange, + }); + const newRanges = ranges.filter((range) => range !== toBeRemovedRange); + setRanges(newRanges); + }; + + const all: React.ReactNode[] = []; + + ranges.forEach((range) => { + const [ + { chapter: fromChapter, verse: fromVerse, verseKey: rangeFrom }, + { chapter: toChapter, verse: toVerse, verseKey: rangeTo }, + ] = parseVerseRange(range); + + const from = `${ + getChapterData(chaptersData, fromChapter).transliteratedName + } ${toLocalizedNumber(Number(fromVerse), lang)}`; + + const to = `${getChapterData(chaptersData, toChapter).transliteratedName} ${toLocalizedNumber( + Number(toVerse), + lang, + )}`; + + all.push( +
+
+ handleVerseClick(RangeItemDirection.From, rangeFrom)} + > + {from} + + {` ${t('common:to')} `} + handleVerseClick(RangeItemDirection.To, rangeTo)} + > + {to} + +
+ {allowClearingRanges && ( + + )} +
, + ); + }); + + if (all.length === 0) return null; + + return all.length > 1 ? ( +
    + {all.map((range, idx) => ( + // eslint-disable-next-line react/no-array-index-key +
    {range}
    + ))} +
+ ) : ( + <>{all} + ); +}; + +export default VerseRangesList; diff --git a/src/components/ReadingGoal/ReadingGoalAmount/index.tsx b/src/components/ReadingGoal/ReadingGoalAmount/index.tsx index 6e4aa1abca..f3400e5310 100644 --- a/src/components/ReadingGoal/ReadingGoalAmount/index.tsx +++ b/src/components/ReadingGoal/ReadingGoalAmount/index.tsx @@ -1,18 +1,14 @@ -import { useContext } from 'react'; - import useTranslation from 'next-translate/useTranslation'; -import DataContext from '@/contexts/DataContext'; -import Link, { LinkVariant } from '@/dls/Link/Link'; +import VerseRangesList from './VerseRangesList'; + import { StreakWithMetadata } from '@/hooks/auth/useGetStreakWithMetadata'; import { GoalType } from '@/types/auth/Goal'; -import { getChapterData } from '@/utils/chapter'; +import { RangeItemDirection } from '@/types/Range'; import { secondsToReadableFormat } from '@/utils/datetime'; import { logButtonClick } from '@/utils/eventLogger'; import { toLocalizedNumber } from '@/utils/locale'; -import { getChapterWithStartingVerseUrl } from '@/utils/navigation'; import { convertFractionToPercent, convertNumberToDecimal } from '@/utils/number'; -import { parseVerseRange } from '@/utils/verseKeys'; interface ReadingGoalAmountProps { goal?: StreakWithMetadata['goal']; @@ -26,7 +22,6 @@ const ReadingGoalAmount: React.FC = ({ context, }) => { const { t, lang } = useTranslation('reading-goal'); - const chaptersData = useContext(DataContext); const percent = convertFractionToPercent(currentActivityDay?.progress || 0); if (!goal || !goal.progress) return null; @@ -36,10 +31,10 @@ const ReadingGoalAmount: React.FC = ({ let action: string | React.ReactNode = ''; - const handleRangeClick = (range: 'from' | 'to', verseKey: string) => { + const handleVerseClick = (direction: RangeItemDirection, verseKey: string) => { return () => { // eslint-disable-next-line @typescript-eslint/naming-convention - logButtonClick(`${context}_goal_range_${range}`, { verse_key: verseKey }); + logButtonClick(`${context}_goal_range_${direction}`, { verse_key: verseKey }); }; }; @@ -56,55 +51,12 @@ const ReadingGoalAmount: React.FC = ({ } if (goalType === GoalType.RANGE) { - const all = []; - - currentActivityDay?.remainingDailyTargetRanges?.forEach((range) => { - const [ - { chapter: fromChapter, verse: fromVerse, verseKey: rangeFrom }, - { chapter: toChapter, verse: toVerse, verseKey: rangeTo }, - ] = parseVerseRange(range); - - const from = `${ - getChapterData(chaptersData, fromChapter).transliteratedName - } ${toLocalizedNumber(Number(fromVerse), lang)}`; - - const to = `${getChapterData(chaptersData, toChapter).transliteratedName} ${toLocalizedNumber( - Number(toVerse), - lang, - )}`; - - all.push( - <> - - {from} - - {` ${t('common:to')} `} - - {to} - - , - ); - }); - - action = - all.length > 1 ? ( -
    - {all.map((range, idx) => ( - // eslint-disable-next-line react/no-array-index-key -
    {range}
    - ))} -
- ) : ( - all - ); + action = ( + + ); } return ( diff --git a/src/components/ReadingGoal/ReadingGoalInput/VerseRangeInput.tsx b/src/components/ReadingGoal/ReadingGoalInput/VerseRangeInput.tsx new file mode 100644 index 0000000000..f4851af97d --- /dev/null +++ b/src/components/ReadingGoal/ReadingGoalInput/VerseRangeInput.tsx @@ -0,0 +1,249 @@ +/* eslint-disable max-lines */ +import { useContext, useMemo, useState } from 'react'; + +import useTranslation from 'next-translate/useTranslation'; + +import styles from './ReadingGoalInput.module.scss'; + +import DataContext from '@/contexts/DataContext'; +import Combobox from '@/dls/Forms/Combobox'; +import ComboboxSize from '@/dls/Forms/Combobox/types/ComboboxSize'; +import { RangeItem, RangeItemPosition } from '@/types/Range'; +import { generateChapterOptions, generateVerseOptions } from '@/utils/generators'; +import { getChapterNumberFromKey, getVerseNumberFromKey, makeVerseKey } from '@/utils/verse'; + +interface VerseRangesInputProps { + rangeStartVerse?: string; + rangeEndVerse?: string; + onRangeChange: (range: { startVerse: string | null; endVerse: string | null }) => void; + + logChange?: ( + field: 'start_verse' | 'end_verse', + data: { currentValue: string | null; newValue: string | null }, + extraData?: { + chapter: string | null; + verse: string | null; + }, + ) => void; +} + +const VerseRangeInput = ({ + rangeStartVerse, + rangeEndVerse, + onRangeChange, + logChange, +}: VerseRangesInputProps) => { + const { t, lang } = useTranslation('reading-goal'); + const chaptersData = useContext(DataContext); + + const [startChapter, setStartChapter] = useState( + rangeStartVerse ? getChapterNumberFromKey(rangeStartVerse).toString() : undefined, + ); + + const [endChapter, setEndChapter] = useState( + rangeEndVerse ? getChapterNumberFromKey(rangeEndVerse).toString() : undefined, + ); + + const chapterOptions = useMemo( + () => generateChapterOptions(chaptersData, lang), + [chaptersData, lang], + ); + + const startingVerseOptions = useMemo( + () => generateVerseOptions(chaptersData, t, lang, startChapter), + [t, lang, chaptersData, startChapter], + ); + + const endingVerseOptions = useMemo( + () => generateVerseOptions(chaptersData, t, lang, endChapter), + [t, lang, chaptersData, endChapter], + ); + + // useEffect(() => { + // if (!rangeStartVerse) { + // setStartChapter(undefined); + // } + // }, [rangeStartVerse]); + + // useEffect(() => { + // if (!rangeEndVerse) { + // setEndChapter(undefined); + // } + // }, [rangeEndVerse]); + + const endingVerse = useMemo(() => { + if (!rangeEndVerse) return undefined; + return getVerseNumberFromKey(rangeEndVerse).toString(); + }, [rangeEndVerse]); + + const startingVerse = useMemo(() => { + if (!rangeStartVerse) return undefined; + return getVerseNumberFromKey(rangeStartVerse).toString(); + }, [rangeStartVerse]); + + // eslint-disable-next-line react-func/max-lines-per-function + const onChapterChange = (chapterPosition: RangeItemPosition) => (chapterId: string) => { + const isStartChapter = chapterPosition === RangeItemPosition.Start; + const oldChapterId = isStartChapter ? startChapter : endChapter; + const setChapter = isStartChapter ? setStartChapter : setEndChapter; + + if (!chapterId || chapterId !== oldChapterId) { + onRangeChange( + isStartChapter + ? { startVerse: null, endVerse: rangeEndVerse } + : { + startVerse: rangeStartVerse, + endVerse: null, + }, + ); + } + + // if the current value is null, we don't need to log it + const currentVerse = isStartChapter ? rangeStartVerse : rangeEndVerse; + if (currentVerse && logChange) { + logChange( + isStartChapter ? 'start_verse' : 'end_verse', + { + currentValue: currentVerse, + newValue: null, + }, + { + chapter: chapterId || null, + verse: (isStartChapter ? startingVerse : endingVerse) || null, + }, + ); + } + + if (!chapterId) { + setChapter(undefined); + } else { + setChapter(chapterId); + } + }; + + const startChapterOptions = useMemo(() => { + if (!endChapter) return chapterOptions; + + const endChapterIdx = Number(endChapter) - 1; + + return chapterOptions.slice(0, endChapterIdx + 1); + }, [chapterOptions, endChapter]); + + const endChapterOptions = useMemo(() => { + if (!startChapter) return chapterOptions; + + const startChapterIdx = Number(startChapter) - 1; + + return chapterOptions.slice(startChapterIdx); + }, [chapterOptions, startChapter]); + + const onVerseChange = (versePosition: RangeItemPosition) => (verseId: string) => { + const isStartVerse = versePosition === RangeItemPosition.Start; + + const newVerseKey = verseId + ? makeVerseKey(isStartVerse ? startChapter : endChapter, verseId) + : null; + + onRangeChange( + isStartVerse + ? { startVerse: newVerseKey, endVerse: rangeEndVerse } + : { + startVerse: rangeStartVerse, + endVerse: newVerseKey, + }, + ); + + if (logChange) { + logChange( + isStartVerse ? 'start_verse' : 'end_verse', + { + currentValue: isStartVerse ? rangeStartVerse : rangeEndVerse, + newValue: newVerseKey, + }, + { + chapter: (isStartVerse ? startChapter : endChapter) || null, + verse: verseId || null, + }, + ); + } + }; + + const getInitialInputValue = (inputType: RangeItem) => { + if (inputType === RangeItem.StartingChapter || inputType === RangeItem.EndingChapter) { + const chapterId = inputType === RangeItem.StartingChapter ? startChapter : endChapter; + if (!chapterId) return undefined; + + return chapterOptions[Number(chapterId) - 1]?.label; + } + + const verseId = inputType === RangeItem.StartingVerse ? startingVerse : endingVerse; + if (!verseId) return ''; + + const verseOptions = + inputType === RangeItem.StartingVerse ? startingVerseOptions : endingVerseOptions; + return verseOptions[Number(verseId) - 1]?.label; + }; + + return ( + <> +
+
+ {t('starting-chapter')}

} + items={startChapterOptions} + value={startChapter} + initialInputValue={getInitialInputValue(RangeItem.StartingChapter)} + onChange={onChapterChange(RangeItemPosition.Start)} + /> +
+ +
+ {t('starting-verse')}

} + items={startingVerseOptions} + value={startingVerse} + initialInputValue={getInitialInputValue(RangeItem.StartingVerse)} + onChange={onVerseChange(RangeItemPosition.Start)} + /> +
+
+
+
+ {t('ending-chapter')}

} + items={endChapterOptions} + value={endChapter} + initialInputValue={getInitialInputValue(RangeItem.EndingChapter)} + onChange={onChapterChange(RangeItemPosition.End)} + /> +
+ +
+ {t('ending-verse')}

} + items={endingVerseOptions} + value={endingVerse} + disabled={!endChapter} + initialInputValue={getInitialInputValue(RangeItem.EndingVerse)} + onChange={onVerseChange(RangeItemPosition.End)} + /> +
+
+ + ); +}; + +export default VerseRangeInput; diff --git a/src/components/ReadingGoal/ReadingGoalInput/index.tsx b/src/components/ReadingGoal/ReadingGoalInput/index.tsx index fc5952ff20..53c29673ed 100644 --- a/src/components/ReadingGoal/ReadingGoalInput/index.tsx +++ b/src/components/ReadingGoal/ReadingGoalInput/index.tsx @@ -1,26 +1,18 @@ /* eslint-disable react-func/max-lines-per-function */ /* eslint-disable max-lines */ -import { useMemo, useContext, useState } from 'react'; +import { useMemo } from 'react'; import classNames from 'classnames'; import useTranslation from 'next-translate/useTranslation'; import styles from './ReadingGoalInput.module.scss'; +import VerseRangeInput from './VerseRangeInput'; import { ReadingGoalTabProps } from '@/components/ReadingGoalPage/hooks/useReadingGoalReducer'; -import DataContext from '@/contexts/DataContext'; -import Combobox from '@/dls/Forms/Combobox'; -import ComboboxSize from '@/dls/Forms/Combobox/types/ComboboxSize'; import Input, { InputSize } from '@/dls/Forms/Input'; import Select, { SelectSize } from '@/dls/Forms/Select'; import { GoalType } from '@/types/auth/Goal'; -import { RangeItem, RangeItemPosition } from '@/types/Range'; -import { - generateChapterOptions, - generateTimeOptions, - generateVerseOptions, -} from '@/utils/generators'; -import { getVerseNumberFromKey, getChapterNumberFromKey, makeVerseKey } from '@/utils/verse'; +import { generateTimeOptions } from '@/utils/generators'; export interface ReadingGoalInputProps { type: GoalType; @@ -44,212 +36,23 @@ const ReadingGoalInput: React.FC = ({ rangeEndVerse, pages, seconds, - onRangeChange, onPagesChange, onSecondsChange, - widthFull = true, - logChange, }) => { const { t, lang } = useTranslation('reading-goal'); - const chaptersData = useContext(DataContext); - const timeOptions = useMemo(() => generateTimeOptions(t, lang), [t, lang]); - const [startChapter, setStartChapter] = useState( - rangeStartVerse ? getChapterNumberFromKey(rangeStartVerse).toString() : undefined, - ); - - const [endChapter, setEndChapter] = useState( - rangeEndVerse ? getChapterNumberFromKey(rangeEndVerse).toString() : undefined, - ); - - const chapterOptions = useMemo( - () => generateChapterOptions(chaptersData, lang), - [chaptersData, lang], - ); - - const startingVerseOptions = useMemo( - () => generateVerseOptions(chaptersData, t, lang, startChapter), - [t, lang, chaptersData, startChapter], - ); - - const endingVerseOptions = useMemo( - () => generateVerseOptions(chaptersData, t, lang, endChapter), - [t, lang, chaptersData, endChapter], - ); - - const endingVerse = useMemo(() => { - if (!rangeEndVerse) return undefined; - return getVerseNumberFromKey(rangeEndVerse).toString(); - }, [rangeEndVerse]); - - const startingVerse = useMemo(() => { - if (!rangeStartVerse) return undefined; - return getVerseNumberFromKey(rangeStartVerse).toString(); - }, [rangeStartVerse]); - - const onChapterChange = (chapterPosition: RangeItemPosition) => (chapterId: string) => { - const isStartChapter = chapterPosition === RangeItemPosition.Start; - const oldChapterId = isStartChapter ? startChapter : endChapter; - const setChapter = isStartChapter ? setStartChapter : setEndChapter; - - if (!chapterId || chapterId !== oldChapterId) { - onRangeChange( - isStartChapter - ? { startVerse: null, endVerse: rangeEndVerse } - : { - startVerse: rangeStartVerse, - endVerse: null, - }, - ); - } - - // if the current value is null, we don't need to log it - const currentVerse = isStartChapter ? rangeStartVerse : rangeEndVerse; - if (currentVerse) { - logChange( - isStartChapter ? 'start_verse' : 'end_verse', - { - currentValue: currentVerse, - newValue: null, - }, - { - chapter: chapterId || null, - verse: (isStartChapter ? startingVerse : endingVerse) || null, - }, - ); - } - - if (!chapterId) { - setChapter(undefined); - } else { - setChapter(chapterId); - } - }; - - const startChapterOptions = useMemo(() => { - if (!endChapter) return chapterOptions; - - const endChapterIdx = Number(endChapter) - 1; - - return chapterOptions.slice(0, endChapterIdx + 1); - }, [chapterOptions, endChapter]); - - const endChapterOptions = useMemo(() => { - if (!startChapter) return chapterOptions; - - const startChapterIdx = Number(startChapter) - 1; - - return chapterOptions.slice(startChapterIdx); - }, [chapterOptions, startChapter]); - - const onVerseChange = (versePosition: RangeItemPosition) => (verseId: string) => { - const isStartVerse = versePosition === RangeItemPosition.Start; - - const newVerseKey = verseId - ? makeVerseKey(isStartVerse ? startChapter : endChapter, verseId) - : null; - - onRangeChange( - isStartVerse - ? { startVerse: newVerseKey, endVerse: rangeEndVerse } - : { - startVerse: rangeStartVerse, - endVerse: newVerseKey, - }, - ); - - logChange( - isStartVerse ? 'start_verse' : 'end_verse', - { - currentValue: isStartVerse ? rangeStartVerse : rangeEndVerse, - newValue: newVerseKey, - }, - { - chapter: (isStartVerse ? startChapter : endChapter) || null, - verse: verseId || null, - }, - ); - }; - - const getInitialInputValue = (inputType: RangeItem) => { - if (inputType === RangeItem.StartingChapter || inputType === RangeItem.EndingChapter) { - const chapterId = inputType === RangeItem.StartingChapter ? startChapter : endChapter; - if (!chapterId) return undefined; - - return chapterOptions[Number(chapterId) - 1]?.label; - } - - const verseId = inputType === RangeItem.StartingVerse ? startingVerse : endingVerse; - if (!verseId) return ''; - - const verseOptions = - inputType === RangeItem.StartingVerse ? startingVerseOptions : endingVerseOptions; - return verseOptions[Number(verseId) - 1]?.label; - }; if (type === GoalType.RANGE) { return ( - <> -
-
- {t('starting-chapter')}

} - items={startChapterOptions} - value={startChapter} - initialInputValue={getInitialInputValue(RangeItem.StartingChapter)} - onChange={onChapterChange(RangeItemPosition.Start)} - /> -
- -
- {t('starting-verse')}

} - items={startingVerseOptions} - value={startingVerse} - initialInputValue={getInitialInputValue(RangeItem.StartingVerse)} - onChange={onVerseChange(RangeItemPosition.Start)} - /> -
-
-
-
- {t('ending-chapter')}

} - items={endChapterOptions} - value={endChapter} - initialInputValue={getInitialInputValue(RangeItem.EndingChapter)} - onChange={onChapterChange(RangeItemPosition.End)} - /> -
- -
- {t('ending-verse')}

} - items={endingVerseOptions} - value={endingVerse} - disabled={!endChapter} - initialInputValue={getInitialInputValue(RangeItem.EndingVerse)} - onChange={onVerseChange(RangeItemPosition.End)} - /> -
-
- + ); } diff --git a/src/components/ReadingGoalPage/utils/validator.test.ts b/src/components/ReadingGoalPage/utils/validator.test.ts new file mode 100644 index 0000000000..529bf1ad33 --- /dev/null +++ b/src/components/ReadingGoalPage/utils/validator.test.ts @@ -0,0 +1,68 @@ +/* eslint-disable react-func/max-lines-per-function */ +import { it, expect, describe } from 'vitest'; + +import { getAllChaptersData } from '../../../utils/chapter'; + +import { isValidVerseRange } from './validator'; + +describe('areValidReadingRanges', () => { + it('invalid startVerse should fail', async () => { + const chaptersData = await getAllChaptersData(); + expect( + isValidVerseRange(chaptersData, { + startVerse: '111111', + endVerse: '1:2', + }), + ).toEqual(false); + }); + + it('invalid endVerse should fail', async () => { + const chaptersData = await getAllChaptersData(); + expect( + isValidVerseRange(chaptersData, { + startVerse: '1:2', + endVerse: '111111', + }), + ).toEqual(false); + }); + + it('invalid startVerse and endVerse should fail', async () => { + const chaptersData = await getAllChaptersData(); + expect( + isValidVerseRange(chaptersData, { + startVerse: '111111', + endVerse: '111111', + }), + ).toEqual(false); + }); + + it('valid startVerse and endVerse should succeed', async () => { + const chaptersData = await getAllChaptersData(); + expect( + isValidVerseRange(chaptersData, { + startVerse: '1:1', + endVerse: '5:1', + }), + ).toEqual(true); + }); + + it('startVerse ahead of endVerse should fail', async () => { + const chaptersData = await getAllChaptersData(); + expect( + isValidVerseRange(chaptersData, { + startVerse: '5:1', + endVerse: '1:1', + }), + ).toEqual(false); + }); + + it('same startVerse and endVerse should succeed', async () => { + const chaptersData = await getAllChaptersData(); + expect( + isValidVerseRange(chaptersData, { + startVerse: '1:1', + endVerse: '1:1', + }), + ).toEqual(true); + }); +}); diff --git a/src/components/ReadingGoalPage/utils/validator.ts b/src/components/ReadingGoalPage/utils/validator.ts index 4a6792cf66..f7a35903ec 100644 --- a/src/components/ReadingGoalPage/utils/validator.ts +++ b/src/components/ReadingGoalPage/utils/validator.ts @@ -1,17 +1,22 @@ /* eslint-disable import/prefer-default-export */ -import { GoalType } from '@/types/auth/Goal'; -import ChaptersData from '@/types/ChaptersData'; import { isValidPageId, isValidVerseKey } from '@/utils/validator'; import { getVerseAndChapterNumbersFromKey } from '@/utils/verse'; +import { GoalType } from 'types/auth/Goal'; +import ChaptersData from 'types/ChaptersData'; const SECONDS_LIMIT = 4 * 60 * 60; // 4 hours const MIN_SECONDS = 60; // 1 minute +interface Range { + startVerse: string; + endVerse: string; +} + interface ReadingGoalPayload { type: GoalType; pages?: number; seconds?: number; - range?: { startVerse: string; endVerse: string }; + range?: Range; } /** @@ -39,22 +44,38 @@ export const validateReadingGoalData = ( // if the user selected a range goal and didn't enter a valid range, disable the next button if (type === GoalType.RANGE) { - if (!range?.startVerse || !range?.endVerse) return false; - if ( - !isValidVerseKey(chaptersData, range.startVerse) || - !isValidVerseKey(chaptersData, range.endVerse) - ) { - return false; - } - - // check if the starting verse key is greater than the ending verse key - const [startingChapter, startingVerse] = getVerseAndChapterNumbersFromKey(range.startVerse); - const [endingChapter, endingVerse] = getVerseAndChapterNumbersFromKey(range.endVerse); - - if (startingChapter === endingChapter && Number(startingVerse) > Number(endingVerse)) { - return false; - } + return isValidVerseRange(chaptersData, range); + } + + return true; +}; + +/** + * Check wether the ranges are valid or not. + * + * @param {ChaptersData} chaptersData + * @param {Range} range + * @returns {boolean} + */ +export const isValidVerseRange = (chaptersData: ChaptersData, range?: Range): boolean => { + if (!range?.startVerse || !range?.endVerse) return false; + if ( + !isValidVerseKey(chaptersData, range.startVerse) || + !isValidVerseKey(chaptersData, range.endVerse) + ) { + return false; } + // check if the starting verse key is greater than the ending verse key + const [startingChapter, startingVerse] = getVerseAndChapterNumbersFromKey(range.startVerse); + const [endingChapter, endingVerse] = getVerseAndChapterNumbersFromKey(range.endVerse); + // if it's the same Surah but in reverse order + if (startingChapter === endingChapter && Number(startingVerse) > Number(endingVerse)) { + return false; + } + // if it's the range Surahs are in reverse order + if (Number(startingChapter) > Number(endingChapter)) { + return false; + } return true; }; diff --git a/src/components/ReadingProgressPage/ProgressPageGoalWidget.tsx b/src/components/ReadingProgressPage/ProgressPageGoalWidget.tsx index 8eece0cf1e..d821daeb09 100644 --- a/src/components/ReadingProgressPage/ProgressPageGoalWidget.tsx +++ b/src/components/ReadingProgressPage/ProgressPageGoalWidget.tsx @@ -25,7 +25,7 @@ const ProgressPageGoalWidget = ({ isLoading, }: ProgressPageGoalWidgetProps) => { const { t, lang } = useTranslation('reading-progress'); - const percent = Math.min(goal?.progress?.percent || 0, 100); + const percent = goal?.isCompleted ? 100 : Math.min(goal?.progress?.percent || 0, 100); const isGoalDone = percent >= 100; const localizedPercent = toLocalizedNumber(percent, lang); diff --git a/src/components/ReadingProgressPage/ReadingHistory/AddReading/AddReading.module.scss b/src/components/ReadingProgressPage/ReadingHistory/AddReading/AddReading.module.scss new file mode 100644 index 0000000000..670dad9d3f --- /dev/null +++ b/src/components/ReadingProgressPage/ReadingHistory/AddReading/AddReading.module.scss @@ -0,0 +1,34 @@ +.calendarMonthSelector { + margin-block-end: var(--spacing-medium); + display: flex; + align-items: center; + margin-inline: auto; + justify-content: center; + gap: var(--spacing-medium); + font-size: var(--font-size-large); +} + +.verseRangesListContainer { + margin-block-start: var(--spacing-mega); +} + +.selectedDateHeaderContainer { + display: flex; +} + +.modalHeader { + .backButton { + position: absolute; + pointer-events: initial; + inset-inline-start: 0; + } +} + +.monthName { + width: calc(3 * var(--spacing-mega)); + text-align: center; +} + +.durationInputWrapper { + margin-block-start: var(--spacing-large); +} diff --git a/src/components/ReadingProgressPage/ReadingHistory/AddReading/AddReadingForm.tsx b/src/components/ReadingProgressPage/ReadingHistory/AddReading/AddReadingForm.tsx new file mode 100644 index 0000000000..88717e33ca --- /dev/null +++ b/src/components/ReadingProgressPage/ReadingHistory/AddReading/AddReadingForm.tsx @@ -0,0 +1,91 @@ +import { useContext, useState } from 'react'; + +import useTranslation from 'next-translate/useTranslation'; + +import styles from './AddReading.module.scss'; + +import VerseRangesList from '@/components/ReadingGoal/ReadingGoalAmount/VerseRangesList'; +import VerseRangeInput from '@/components/ReadingGoal/ReadingGoalInput/VerseRangeInput'; +import { isValidVerseRange } from '@/components/ReadingGoalPage/utils/validator'; +import DataContext from '@/contexts/DataContext'; +import Button from '@/dls/Button/Button'; +import DurationInput from '@/dls/DurationInput'; +import { logButtonClick } from '@/utils/eventLogger'; + +interface AddReadingFormProps { + ranges: string[]; + setRanges: (ranges: string[]) => void; + + totalSeconds: number; + setTotalSeconds: (totalSeconds: number) => void; + + isFetchingSeconds: boolean; + totalSecondsError?: string; +} + +const AddReadingForm = ({ + ranges, + setRanges, + totalSeconds, + setTotalSeconds, + isFetchingSeconds, + totalSecondsError, +}: AddReadingFormProps) => { + const chaptersData = useContext(DataContext); + const { t } = useTranslation('reading-progress'); + + const [rangeStart, setRangeStart] = useState(null); + const [rangeEnd, setRangeEnd] = useState(null); + + const onAddClick = () => { + if (!rangeStart || !rangeEnd) return; + const newRanges = [...ranges, `${rangeStart}-${rangeEnd}`]; + logButtonClick('add_reading', { + range: `${rangeStart}-${rangeEnd}`, + }); + setRanges(newRanges); + setRangeStart(undefined); + setRangeEnd(undefined); + }; + + const onRangeChange = ({ startVerse, endVerse }) => { + setRangeStart(startVerse); + setRangeEnd(endVerse); + }; + + const getIsAddButtonDisabled = () => { + return !isValidVerseRange(chaptersData, { + startVerse: rangeStart, + endVerse: rangeEnd, + }); + }; + + return ( + <> + + + +
+ +
+ +
+ +
+ + ); +}; + +export default AddReadingForm; diff --git a/src/components/ReadingProgressPage/ReadingHistory/AddReading/index.tsx b/src/components/ReadingProgressPage/ReadingHistory/AddReading/index.tsx new file mode 100644 index 0000000000..737c721def --- /dev/null +++ b/src/components/ReadingProgressPage/ReadingHistory/AddReading/index.tsx @@ -0,0 +1,285 @@ +/* eslint-disable max-lines */ +import { useMemo, useState, useEffect, useCallback } from 'react'; + +import useTranslation from 'next-translate/useTranslation'; +import { useSWRConfig } from 'swr'; +import useSWRImmutable from 'swr/immutable'; + +import styles from './AddReading.module.scss'; +import AddReadingForm from './AddReadingForm'; + +import buildTranslatedErrorMessageByErrorId from '@/components/FormBuilder/buildTranslatedErrorMessageByErrorId'; +import Button, { ButtonShape, ButtonSize, ButtonVariant } from '@/dls/Button/Button'; +import Calendar from '@/dls/Calendar'; +import { ModalSize } from '@/dls/Modal/Content'; +import Modal from '@/dls/Modal/Modal'; +import Spinner from '@/dls/Spinner/Spinner'; +import { ToastStatus, useToast } from '@/dls/Toast/Toast'; +import useGetMushaf from '@/hooks/useGetMushaf'; +import ChevronLeft from '@/icons/chevron-left.svg'; +import ChevronRight from '@/icons/chevron-right.svg'; +import ArrowLeft from '@/icons/west.svg'; +import { ActivityDayType } from '@/types/auth/ActivityDay'; +import ErrorMessageId from '@/types/ErrorMessageId'; +import { getFilterActivityDaysParamsOfCurrentMonth } from '@/utils/activity-day'; +import { estimateRangesReadingTime, updateActivityDay } from '@/utils/auth/api'; +import { + makeEstimateRangesReadingTimeUrl, + makeFilterActivityDaysUrl, + makeStreakUrl, +} from '@/utils/auth/apiPaths'; +import { + dateToReadableFormat, + getCurrentDay, + getCurrentMonth, + getMonthsInYear, +} from '@/utils/datetime'; +import { logValueChange, logButtonClick, logFormSubmission } from '@/utils/eventLogger'; + +const AddReading = () => { + const { t, lang } = useTranslation('reading-progress'); + const [isOpen, setIsOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const [selectedMonth, setSelectedMonth] = useState(() => getCurrentMonth()); + const [selectedDate, setSelectedDate] = useState(null); + const selectedYear = useMemo(() => new Date().getFullYear(), []); + + const [ranges, setRanges] = useState([]); + const [totalSeconds, setTotalSeconds] = useState(0); + const [totalSecondsError, setTotalSecondsError] = useState(null); + + const months = useMemo(() => getMonthsInYear(selectedYear, lang), [selectedYear, lang]); + const mushaf = useGetMushaf(); + const toast = useToast(); + const { cache, mutate } = useSWRConfig(); + + const { isValidating, data } = useSWRImmutable( + ranges.length > 0 ? makeEstimateRangesReadingTimeUrl({ ranges }) : null, + () => estimateRangesReadingTime({ ranges }), + ); + + useEffect(() => { + setTotalSeconds(data?.data?.seconds || 0); + }, [data]); + + const onClose = () => { + setIsOpen(false); + // reset ranges + setRanges([]); + // reset selected date + setSelectedDate(null); + // reset seconds error + setTotalSecondsError(null); + }; + + const onOpenClick = () => { + logButtonClick('open_add_reading_modal'); + setIsOpen(true); + }; + + const selectedMonthObj = useMemo(() => { + if (!selectedMonth) return null; + return months.find((month) => month.id === selectedMonth); + }, [selectedMonth, months]); + + const onMonthBackClick = () => { + const newMonth = selectedMonth - 1; + logValueChange('add_reading_month', selectedMonth, newMonth, { + year: selectedYear, + }); + setSelectedMonth(newMonth); + }; + + const onMonthForwardClick = () => { + const newMonth = selectedMonth + 1; + logValueChange('add_reading_month', selectedMonth, newMonth, { + year: selectedYear, + }); + setSelectedMonth(newMonth); + }; + + const onGoBackClick = () => { + logButtonClick('add_reading_back_to_calendar'); + setSelectedDate(null); + }; + + const onDayClick = (day: number, dateString: string) => { + logValueChange('add_reading_day', selectedDate, dateString, { + day, + }); + setSelectedDate(dateString); + }; + + const onTotalSecondsChange = useCallback( + (newTotalSeconds: number) => { + setTotalSeconds(newTotalSeconds); + if (totalSecondsError && newTotalSeconds > 0) { + setTotalSecondsError(null); + } + }, + [totalSecondsError], + ); + + /** + * Check if a day is disabled. A day is disabled if it's later than today. + * + * @param {number} day + * @returns {boolean} + */ + const getIsDayDisabled = (day: number): boolean => { + const currentMonth = getCurrentMonth(); + // if the selected month is before the current month, don't disable any day + if (selectedMonth < currentMonth) { + return false; + } + // if the selected month is after the current month, disable all of the days + if (selectedMonth > currentMonth) { + return true; + } + + const currentDay = getCurrentDay(); + + // for the current month, we need to check which days are later than today and disable them + if (day > currentDay) { + return true; + } + + return false; + }; + + // eslint-disable-next-line react-func/max-lines-per-function + const onSubmitClick = async () => { + if (totalSeconds < 1) { + setTotalSecondsError( + buildTranslatedErrorMessageByErrorId(ErrorMessageId.RequiredField, t('reading-time'), t), + ); + return; + } + + const payload = { + ranges, + seconds: totalSeconds, + date: selectedDate, + type: ActivityDayType.QURAN, + mushafId: mushaf, + }; + logFormSubmission('add_reading', payload); + setIsSubmitting(true); + + try { + await updateActivityDay(payload); + // invalidate the current month's history cache to refetch the data if we navigated to it + const currentMonthHistoryUrl = makeFilterActivityDaysUrl( + getFilterActivityDaysParamsOfCurrentMonth(), + ); + cache.delete(currentMonthHistoryUrl); + mutate(makeStreakUrl()); + // close the modal + onClose(); + toast(t('add-data-success'), { + status: ToastStatus.Success, + }); + } catch (e) { + toast(t('common:error.general'), { + status: ToastStatus.Error, + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <> + + + + + + {selectedDate ? ( +
+ +

+ {t('history-for', { + date: dateToReadableFormat(selectedDate, lang, { + year: 'numeric', + }), + })} +

+
+ ) : ( +

{t('manually-add')}

+ )} +
+
+ {!selectedDate ? ( + <> +
+ +

{selectedMonthObj.name}

+ +
+ + + ) : ( + + )} +
+ + + {t('common:cancel')} + + + {isSubmitting ? : t('common:submit')} + + +
+ + ); +}; + +export default AddReading; diff --git a/src/components/ReadingProgressPage/ReadingHistory/DaysCalendar.tsx b/src/components/ReadingProgressPage/ReadingHistory/DaysCalendar.tsx index 4532b36829..e75b11c974 100644 --- a/src/components/ReadingProgressPage/ReadingHistory/DaysCalendar.tsx +++ b/src/components/ReadingProgressPage/ReadingHistory/DaysCalendar.tsx @@ -1,14 +1,10 @@ import { useMemo } from 'react'; -import classNames from 'classnames'; -import useTranslation from 'next-translate/useTranslation'; - -import styles from './ReadingHistory.module.scss'; import ReadingStats from './ReadingStats'; +import Calendar from '@/dls/Calendar'; import { ActivityDay } from '@/types/auth/ActivityDay'; import { logButtonClick } from '@/utils/eventLogger'; -import { toLocalizedNumber } from '@/utils/locale'; interface DaysCalendarProps { month: { id: number; name: string; daysCount: number }; @@ -16,6 +12,7 @@ interface DaysCalendarProps { days: ActivityDay[]; selectedDate: string | null; setSelectedDate: (date: string | null) => void; + isLoading?: boolean; } const DaysCalendar: React.FC = ({ @@ -24,12 +21,8 @@ const DaysCalendar: React.FC = ({ days, selectedDate, setSelectedDate, + isLoading, }) => { - const { lang } = useTranslation('reading-progress'); - - // YYYY-MM - const monthDate = `${year}-${month.id.toString().padStart(2, '0')}`; - const dateToDayMap = useMemo(() => { const map: Record = {}; @@ -49,39 +42,26 @@ const DaysCalendar: React.FC = ({ return ; } - const onDayClick = (date: string, dayNumber: number) => { + const onDayClick = (dayNumber: number, dateString: string) => { logButtonClick('reading_history_day', { month: month.id, year, day: dayNumber, }); - setSelectedDate(date); + setSelectedDate(dateString); }; return ( -
- {/* eslint-disable-next-line @typescript-eslint/naming-convention */} - {Array.from({ length: month.daysCount }).map((_, index) => { - const day = index + 1; - const date = `${monthDate}-${day.toString().padStart(2, '0')}`; - const dayData = dateToDayMap[date]; - - const isDisabled = !dayData; - - return ( -
6 && styles.bordered)}> - -
- ); - })} -
+ { + const dayData = dateToDayMap[dateString]; + return !dayData; + }} + /> ); }; diff --git a/src/components/ReadingProgressPage/ReadingHistory/MonthModal.tsx b/src/components/ReadingProgressPage/ReadingHistory/MonthModal.tsx index e78e7480e0..5efa39c3b7 100644 --- a/src/components/ReadingProgressPage/ReadingHistory/MonthModal.tsx +++ b/src/components/ReadingProgressPage/ReadingHistory/MonthModal.tsx @@ -9,7 +9,6 @@ import DataFetcher from '@/components/DataFetcher'; import Button, { ButtonSize, ButtonVariant } from '@/dls/Button/Button'; import ContentModal from '@/dls/ContentModal/ContentModal'; import ContentModalHandles from '@/dls/ContentModal/types/ContentModalHandles'; -import Spinner from '@/dls/Spinner/Spinner'; import ArrowLeft from '@/icons/west.svg'; import { ActivityDay } from '@/types/auth/ActivityDay'; import { Pagination } from '@/types/auth/GetBookmarksByCollectionId'; @@ -70,20 +69,16 @@ const MonthModal = ({ month, year, onClose }: MonthModalProps) => {
{ - return ( - <> - - - - ); - }} + loading={() => ( + + )} fetcher={privateFetcher} render={(response) => { const data = response as { data: ActivityDay[]; pagination: Pagination }; diff --git a/src/components/ReadingProgressPage/ReadingHistory/ReadingHistory.module.scss b/src/components/ReadingProgressPage/ReadingHistory/ReadingHistory.module.scss index a406baecab..18de512479 100644 --- a/src/components/ReadingProgressPage/ReadingHistory/ReadingHistory.module.scss +++ b/src/components/ReadingProgressPage/ReadingHistory/ReadingHistory.module.scss @@ -16,10 +16,23 @@ gap: var(--spacing-large); } +.titleContainer { + display: flex; + align-items: center; + gap: var(--spacing-medium); + justify-content: space-between; + + @include breakpoints.smallerThanTablet { + flex-direction: column; + align-items: flex-start; + } +} + .title { display: flex; align-items: center; gap: var(--spacing-medium); + justify-content: space-between; } .modalHeader { @@ -77,49 +90,6 @@ margin-block-end: var(--spacing-medium); } -.calendarContainer { - display: grid; - grid-template-columns: repeat(7, 1fr); - max-width: calc(var(--spacing-mega) * 12); - margin-inline: auto; - - & > div { - padding-block: var(--spacing-small); - - &.bordered { - border-block-start: 1px solid var(--color-background-alternative-medium); - } - - & > button { - background: none; - border: none; - color: var(--color-text-default); - font-weight: var(--font-weight-bold); - margin-inline: auto; - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border-radius: var(--border-radius-pill); - - &.disabled { - opacity: var(--opacity-50); - font-weight: var(--font-weight-normal); - cursor: not-allowed; - } - - &:hover { - background: var(--color-background-alternative-medium); - } - } - } -} - -.calendarSpinner { - margin-block-end: var(--spacing-medium); -} - .readingInfo { font-size: var(--font-size-large); display: flex; diff --git a/src/components/ReadingProgressPage/ReadingHistory/ReadingStats.tsx b/src/components/ReadingProgressPage/ReadingHistory/ReadingStats.tsx index 17915852e7..09a4053f7b 100644 --- a/src/components/ReadingProgressPage/ReadingHistory/ReadingStats.tsx +++ b/src/components/ReadingProgressPage/ReadingHistory/ReadingStats.tsx @@ -40,6 +40,8 @@ const ReadingStats: React.FC = ({ activityDay }) => { }; }; + const secondsRead = activityDay.secondsRead + (activityDay.manuallyAddedSeconds || 0); + return (
@@ -53,7 +55,7 @@ const ReadingStats: React.FC = ({ activityDay }) => {

- {secondsToReadableFormat(activityDay.secondsRead, t, lang)} + {secondsToReadableFormat(secondsRead, t, lang)}

diff --git a/src/components/ReadingProgressPage/ReadingHistory/index.tsx b/src/components/ReadingProgressPage/ReadingHistory/index.tsx index b13b2d90fe..a841e6483c 100644 --- a/src/components/ReadingProgressPage/ReadingHistory/index.tsx +++ b/src/components/ReadingProgressPage/ReadingHistory/index.tsx @@ -5,12 +5,13 @@ import useTranslation from 'next-translate/useTranslation'; import pageStyles from '../ReadingProgressPage.module.scss'; +import AddReading from './AddReading'; import MonthModal from './MonthModal'; import styles from './ReadingHistory.module.scss'; import Select from '@/dls/Forms/Select'; import SelectionCard from '@/dls/SelectionCard/SelectionCard'; -import { getFullMonthName } from '@/utils/datetime'; +import { getMonthsInYear } from '@/utils/datetime'; import { logButtonClick, logValueChange } from '@/utils/eventLogger'; import { toLocalizedNumber } from '@/utils/locale'; @@ -32,18 +33,7 @@ const ReadingHistory = () => { ); }, [lang]); - const { months } = useMemo(() => { - const all: { id: number; name: string; daysCount: number }[] = []; - - for (let i = 1; i <= 12; i += 1) { - const monthDate = new Date(selectedYear, i, 0); - const daysInMonth = monthDate.getDate(); - - all.push({ id: i, name: getFullMonthName(monthDate, lang), daysCount: daysInMonth }); - } - - return { months: all }; - }, [selectedYear, lang]); + const months = useMemo(() => getMonthsInYear(selectedYear, lang), [selectedYear, lang]); const selectedMonthObj = useMemo(() => { if (!selectedMonth) return null; @@ -79,15 +69,18 @@ const ReadingHistory = () => { /> )} -
-

{t('history')}

- +
+
diff --git a/src/components/dls/Calendar/Calendar.module.scss b/src/components/dls/Calendar/Calendar.module.scss new file mode 100644 index 0000000000..c086b7c784 --- /dev/null +++ b/src/components/dls/Calendar/Calendar.module.scss @@ -0,0 +1,42 @@ +.outerContainer { + min-height: calc(var(--spacing-mega) * 9.3); +} + +.calendarContainer { + display: grid; + grid-template-columns: repeat(7, 1fr); + max-width: calc(var(--spacing-mega) * 12); + margin-inline: auto; + + & > div { + padding-block: var(--spacing-small); + + &.bordered { + border-block-start: 1px solid var(--color-background-alternative-medium); + } + + & > button { + background: none; + border: none; + color: var(--color-text-default); + font-weight: var(--font-weight-bold); + margin-inline: auto; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--border-radius-pill); + + &.disabled { + opacity: var(--opacity-50); + font-weight: var(--font-weight-normal); + cursor: not-allowed; + } + + &:hover { + background: var(--color-background-alternative-medium); + } + } + } +} diff --git a/src/components/dls/Calendar/Calendar.stories.tsx b/src/components/dls/Calendar/Calendar.stories.tsx new file mode 100644 index 0000000000..d3193b3e62 --- /dev/null +++ b/src/components/dls/Calendar/Calendar.stories.tsx @@ -0,0 +1,37 @@ +import Calendar from './index'; + +export default { + title: 'dls/Calendar', + component: Calendar, + args: { + month: new Date().getMonth(), + year: new Date().getFullYear(), + isLoading: false, + }, + argTypes: { + month: { + description: 'The month of the calendar', + // eslint-disable-next-line @typescript-eslint/naming-convention + options: new Array(12).fill(0).map((_, i) => i + 1), + control: 'select', + }, + year: { + description: 'The year of the calendar', + control: 'number', + }, + isLoading: { + description: '[OPTIONAL] indicate whether the calendar is in loading state or not', + control: 'boolean', + }, + }, +}; + +const Template = (args) => ; + +export const DefaultCalendar = Template.bind({}); +DefaultCalendar.args = {}; + +export const LoadingCalendar = Template.bind({}); +LoadingCalendar.args = { + isLoading: true, +}; diff --git a/src/components/dls/Calendar/index.tsx b/src/components/dls/Calendar/index.tsx new file mode 100644 index 0000000000..74d6fbad12 --- /dev/null +++ b/src/components/dls/Calendar/index.tsx @@ -0,0 +1,74 @@ +import { useCallback } from 'react'; + +import classNames from 'classnames'; +import useTranslation from 'next-translate/useTranslation'; + +import Spinner from '../Spinner/Spinner'; + +import styles from './Calendar.module.scss'; + +import { getMonthDateObject, numberToPaddedString } from '@/utils/datetime'; +import { toLocalizedNumber } from '@/utils/locale'; + +interface CalendarProps { + month: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + year: number; + getIsDayDisabled?: (day: number, dateString: string) => boolean; + onDayClick?: (day: number, dateString: string) => void; + isLoading?: boolean; +} + +const Calendar = ({ month, year, getIsDayDisabled, onDayClick, isLoading }: CalendarProps) => { + const { lang } = useTranslation(); + + // YYYY-MM + const monthDateString = `${year}-${numberToPaddedString(month)}`; + + const monthDateObj = getMonthDateObject(year, month); + const daysInMonth = monthDateObj.getDate(); + + const getIsDisabled = useCallback( + (day: number, dateString: string) => { + // if the calendar is loading, disable all days + if (isLoading) return true; + + if (getIsDayDisabled) return getIsDayDisabled(day, dateString); + + // if there is no custom logic to disable days, don't disable any day + return false; + }, + [getIsDayDisabled, isLoading], + ); + + return ( +
+ {isLoading && } +
+ {/* eslint-disable-next-line @typescript-eslint/naming-convention */} + {Array.from({ length: daysInMonth }).map((_, index) => { + const day = index + 1; + const dateString = `${monthDateString}-${numberToPaddedString(day)}`; + + const isDisabled = getIsDisabled(day, dateString); + + const handleDayClick = () => onDayClick?.(day, dateString); + + return ( +
6 && styles.bordered)}> + +
+ ); + })} +
+
+ ); +}; + +export default Calendar; diff --git a/src/components/dls/DurationInput/DurationInput.module.scss b/src/components/dls/DurationInput/DurationInput.module.scss new file mode 100644 index 0000000000..abdeeb4299 --- /dev/null +++ b/src/components/dls/DurationInput/DurationInput.module.scss @@ -0,0 +1,114 @@ +.label { + font-size: var(--font-size-normal); + font-weight: var(--font-weight-semibold); + color: var(--color-text-default); +} + +.durationInputContainer { + display: flex; + align-items: center; + margin-block-start: var(--spacing-medium); + width: 100%; + + & > div { + width: 33.3%; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-xsmall); + + label { + font-size: var(--font-size-small); + font-weight: var(--font-weight-semibold); + color: var(--color-text-default); + } + } + + input { + // hide arrows on number input (@see: https://stackoverflow.com/questions/3790935/can-i-hide-the-html5-number-input-s-spin-box) + -moz-appearance: textfield; /* Firefox */ + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + display: block; + width: 100%; + max-width: auto; + box-sizing: border-box; + padding-inline: var(--spacing-small); + padding-block: var(--spacing-small); + text-align: center; + background: transparent; + border: none; + border-block: 1px solid var(--color-borders-hairline); + font-size: var(--font-size-normal); + color: var(--color-text-default); + + &:focus { + outline: none; + } + } + + // first input, border radius on left only + & > div:first-child > input { + [dir="ltr"] & { + border-start-start-radius: var(--border-radius-rounded); + border-end-start-radius: var(--border-radius-rounded); + } + + [dir="rtl"] & { + border-start-end-radius: var(--border-radius-rounded); + border-end-end-radius: var(--border-radius-rounded); + } + + border: 1px solid var(--color-borders-hairline); + } + + // last input, border radius on right only + & > div:last-child > input { + [dir="ltr"] & { + border-start-end-radius: var(--border-radius-rounded); + border-end-end-radius: var(--border-radius-rounded); + } + + [dir="rtl"] & { + border-start-start-radius: var(--border-radius-rounded); + border-end-start-radius: var(--border-radius-rounded); + } + + border: 1px solid var(--color-borders-hairline); + } +} + +.disabled { + opacity: var(--opacity-50); +} + +.disabledInput { + background-color: var(--color-background-alternative-faded) !important; +} + +.loadingSpinner { + margin: auto; + position: fixed; + inset-inline-start: 50%; +} + +.error { + input { + border-color: var(--color-text-error) !important; + color: var(--color-text-error); + } + + label { + color: var(--color-text-error) !important; + } +} + +.errorMessage { + color: var(--color-text-error); + border-color: var(--color-text-error); + margin-block-start: var(--spacing-xsmall); +} diff --git a/src/components/dls/DurationInput/DurationInput.stories.tsx b/src/components/dls/DurationInput/DurationInput.stories.tsx new file mode 100644 index 0000000000..de01db4ae0 --- /dev/null +++ b/src/components/dls/DurationInput/DurationInput.stories.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react'; + +import DurationInput from '.'; + +export default { + title: 'dls/DurationInput', + component: DurationInput, + argTypes: {}, +}; + +export const Default = (args) => { + const [totalSeconds, setTotalSeconds] = useState(0); + + return ( + setTotalSeconds(s)} + /> + ); +}; + +export const WithDisabled = Default.bind({}); +WithDisabled.args = { + disabled: true, +}; + +export const WithLoading = Default.bind({}); +WithLoading.args = { + isLoading: true, +}; diff --git a/src/components/dls/DurationInput/index.tsx b/src/components/dls/DurationInput/index.tsx new file mode 100644 index 0000000000..fd2327d841 --- /dev/null +++ b/src/components/dls/DurationInput/index.tsx @@ -0,0 +1,123 @@ +import { ChangeEvent, useEffect, useState } from 'react'; + +import classNames from 'classnames'; +import useTranslation from 'next-translate/useTranslation'; + +import styles from './DurationInput.module.scss'; + +import Spinner from '@/dls/Spinner/Spinner'; +import { convertNumberToDecimal } from '@/utils/number'; + +interface DurationInputProps { + totalSeconds: number; + onTotalSecondsChange: (totalSeconds: number) => void; + disabled?: boolean; + isLoading?: boolean; + label?: string; + error?: string; +} + +const commonInputProps: React.InputHTMLAttributes = { + type: 'number', + min: 0, +}; + +const DurationInput = ({ + totalSeconds, + onTotalSecondsChange, + disabled = false, + isLoading = false, + label, + error, +}: DurationInputProps) => { + const [hours, setHours] = useState(0); + const [minutes, setMinutes] = useState(0); + const [seconds, setSeconds] = useState(0); + const { t } = useTranslation('common'); + + const isDisabled = disabled || isLoading; + + const handleChange = (setter: (value: number) => void) => (e: ChangeEvent) => { + const value = Number(e.target.value); + if (value < 0) return; + + setter(value); + }; + + useEffect(() => { + onTotalSecondsChange(hours * 3600 + minutes * 60 + seconds); + }, [onTotalSecondsChange, hours, minutes, seconds]); + + useEffect(() => { + const newHours = Math.floor(totalSeconds / 3600); + const newMinutes = Math.floor((totalSeconds % 3600) / 60); + const newSeconds = totalSeconds % 60; + + setHours(convertNumberToDecimal(newHours, 1)); + setMinutes(convertNumberToDecimal(newMinutes, 1)); + setSeconds(convertNumberToDecimal(newSeconds, 1)); + }, [totalSeconds]); + + const commonInputClassName = classNames({ + [styles.disabledInput]: isDisabled, + }); + + return ( +
+ {isLoading && } + {label && ( + + )} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {error &&

{error}

} +
+ ); +}; + +export default DurationInput; diff --git a/src/components/dls/Modal/Action.module.scss b/src/components/dls/Modal/Action.module.scss index 3f62868b4e..485bf71b2c 100644 --- a/src/components/dls/Modal/Action.module.scss +++ b/src/components/dls/Modal/Action.module.scss @@ -31,3 +31,8 @@ background-color: var(--color-background-alternative-medium); color: var(--color-text-faded); } + +.primary { + background-color: var(--color-background-inverse); + color: var(--color-text-inverse); +} diff --git a/src/components/dls/Modal/Action.tsx b/src/components/dls/Modal/Action.tsx index 5a15f472f3..4a4069445d 100644 --- a/src/components/dls/Modal/Action.tsx +++ b/src/components/dls/Modal/Action.tsx @@ -1,15 +1,25 @@ import React, { MouseEventHandler } from 'react'; +import classNames from 'classnames'; + import styles from './Action.module.scss'; type ActionProps = { children: React.ReactNode; onClick?: MouseEventHandler; isDisabled?: boolean; + isPrimary?: boolean; }; -const Action = ({ children, onClick, isDisabled }: ActionProps) => ( - ); diff --git a/src/components/dls/Modal/Content.module.scss b/src/components/dls/Modal/Content.module.scss index cdb54fd3e6..f31328c80c 100644 --- a/src/components/dls/Modal/Content.module.scss +++ b/src/components/dls/Modal/Content.module.scss @@ -95,15 +95,24 @@ &:focus { outline: none; } + @include breakpoints.desktop { border-radius: var(--border-radius-rounded); width: constants.$desktopWidth; - max-width: constants.$desktopMaxWidth; bottom: inherit; left: 50%; top: 50%; transform: translate(-50%, -50%); + + &.medium { + max-width: constants.$desktopMaxWidthMedium; + } + + &.large { + max-width: constants.$desktopMaxWidthLarge; + } } + &.topSheetOnMobile { @include breakpoints.smallerThanTablet { // add 2.5% spacing on the left and right, on mobile @@ -115,40 +124,41 @@ bottom: unset; } } + z-index: var(--z-index-modal); } .content[data-state="open"] { - animation: mobileContentIn constants.$animationDuration - constants.$animationEasing, + animation: + mobileContentIn constants.$animationDuration constants.$animationEasing, fadeIn constants.$animationDuration constants.$animationEasing; &.topSheetOnMobile { @include breakpoints.smallerThanTablet { - animation: topSheetOnMobileIn constants.$animationDuration - constants.$animationEasing, + animation: + topSheetOnMobileIn constants.$animationDuration constants.$animationEasing, fadeIn constants.$animationDuration constants.$animationEasing; } } @include breakpoints.tablet { - animation: desktopContentIn constants.$animationDuration - constants.$animationEasing, + animation: + desktopContentIn constants.$animationDuration constants.$animationEasing, fadeIn constants.$animationDuration constants.$animationEasing; } } .content[data-state="closed"] { - animation: mobileContentOut constants.$animationDuration - constants.$animationEasing, + animation: + mobileContentOut constants.$animationDuration constants.$animationEasing, fadeIn constants.$animationDuration constants.$animationEasing; &.topSheetOnMobile { @include breakpoints.smallerThanTablet { - animation: topSheetOnMobileOut constants.$animationDuration - constants.$animationEasing, + animation: + topSheetOnMobileOut constants.$animationDuration constants.$animationEasing, fadeOut constants.$animationDuration constants.$animationEasing; } } @include breakpoints.tablet { - animation: desktopContentOut constants.$animationDuration - constants.$animationEasing, + animation: + desktopContentOut constants.$animationDuration constants.$animationEasing, fadeOut constants.$animationDuration constants.$animationEasing; } } diff --git a/src/components/dls/Modal/Content.tsx b/src/components/dls/Modal/Content.tsx index 25b96163fb..54df2e773d 100644 --- a/src/components/dls/Modal/Content.tsx +++ b/src/components/dls/Modal/Content.tsx @@ -1,21 +1,35 @@ -import React from 'react'; +import React, { ComponentProps } from 'react'; import * as DialogPrimitive from '@radix-ui/react-dialog'; import classNames from 'classnames'; import styles from './Content.module.scss'; -const Content = ({ +export enum ModalSize { + MEDIUM = 'medium', + LARGE = 'large', +} + +interface ContentProps extends ComponentProps { + isPropagationStopped?: boolean; + isBottomSheetOnMobile?: boolean; + contentClassName?: string; + size?: ModalSize; +} + +const Content: React.FC = ({ children, isPropagationStopped, isBottomSheetOnMobile, contentClassName, + size = ModalSize.MEDIUM, ...props }) => ( void; + size?: ModalSize; }; + const Modal = ({ children, trigger, @@ -34,6 +36,7 @@ const Modal = ({ contentClassName, isBottomSheetOnMobile = true, isInvertedOverlay = false, + size, }: ModalProps) => ( {trigger && ( @@ -51,6 +54,7 @@ const Modal = ({ onPointerDownOutside={onClickOutside} isBottomSheetOnMobile={isBottomSheetOnMobile} contentClassName={contentClassName} + size={size} > {children} diff --git a/src/components/dls/Modal/_constants.scss b/src/components/dls/Modal/_constants.scss index d5f43ef2fd..f9bec483bd 100644 --- a/src/components/dls/Modal/_constants.scss +++ b/src/components/dls/Modal/_constants.scss @@ -1,5 +1,8 @@ $animationDuration: 0.35s; $animationEasing: cubic-bezier(0.4, 0, 0.2, 1); + $desktopWidth: 90vw; -$desktopMaxWidth: calc(30.5 * var(--spacing-medium)); +$desktopMaxWidthMedium: calc(30.5 * var(--spacing-medium)); +$desktopMaxWidthLarge: calc(36 * var(--spacing-large)); + $mobileWidth: 100vw; diff --git a/src/hooks/auth/useGetStreakWithMetadata.ts b/src/hooks/auth/useGetStreakWithMetadata.ts index 5ba1003441..cac59bca29 100644 --- a/src/hooks/auth/useGetStreakWithMetadata.ts +++ b/src/hooks/auth/useGetStreakWithMetadata.ts @@ -170,7 +170,11 @@ const useGetStreakWithMetadata = ({ activityDays.forEach((day) => { result[day.date] = { ...day, - hasRead: day.pagesRead > 0 || day.secondsRead > 0 || day.ranges.length > 0, + hasRead: + day.pagesRead > 0 || + day.secondsRead > 0 || + day.ranges.length > 0 || + day.manuallyAddedSeconds > 0, }; }); diff --git a/src/hooks/useGetMushaf.ts b/src/hooks/useGetMushaf.ts new file mode 100644 index 0000000000..9d6757950a --- /dev/null +++ b/src/hooks/useGetMushaf.ts @@ -0,0 +1,22 @@ +import { shallowEqual, useSelector } from 'react-redux'; + +import { selectQuranFont, selectQuranMushafLines } from '@/redux/slices/QuranReader/styles'; +import { Mushaf } from '@/types/QuranReader'; +import { getMushafId } from '@/utils/api'; + +/** + * Instead of repeating using multiple selectors to get the MushafId + * in multiple components, we are introducing this hook to keep it DRY. + * + * TODO: apply it to everywhere using the mushafId + * + * @returns {Mushaf} + */ +const useGetMushaf = (): Mushaf => { + const quranFont = useSelector(selectQuranFont, shallowEqual); + const mushafLines = useSelector(selectQuranMushafLines, shallowEqual); + const { mushaf } = getMushafId(quranFont, mushafLines); + return mushaf; +}; + +export default useGetMushaf; diff --git a/src/utils/activity-day.ts b/src/utils/activity-day.ts index 6c90a86109..40d5b249e1 100644 --- a/src/utils/activity-day.ts +++ b/src/utils/activity-day.ts @@ -1,9 +1,9 @@ -import { makeDateRangeFromMonth } from './datetime'; +import { getCurrentMonth, makeDateRangeFromMonth } from './datetime'; import { ActivityDayType, FilterActivityDaysParams } from '@/types/auth/ActivityDay'; export const getFilterActivityDaysParamsOfCurrentMonth = (): FilterActivityDaysParams => { - const currentMonth = new Date().getMonth() + 1; + const currentMonth = getCurrentMonth(); const currentYear = new Date().getFullYear(); return getFilterActivityDaysParams(currentMonth, currentYear); diff --git a/src/utils/auth/api.ts b/src/utils/auth/api.ts index 475561182c..db5bd44380 100644 --- a/src/utils/auth/api.ts +++ b/src/utils/auth/api.ts @@ -43,6 +43,7 @@ import { makeGoalUrl, makeFilterActivityDaysUrl, makeStreakUrl, + makeEstimateRangesReadingTimeUrl, makePostReflectionViewsUrl, } from '@/utils/auth/apiPaths'; import { fetcher } from 'src/api'; @@ -206,6 +207,12 @@ export const updateActivityDay = async ({ }: UpdateActivityDayBody): Promise => postRequest(makeActivityDaysUrl({ mushafId, type }), body); +export const estimateRangesReadingTime = async (body: { + ranges: string[]; +}): Promise<{ data: { seconds: number } }> => { + return privateFetcher(makeEstimateRangesReadingTimeUrl(body)); +}; + export const getStreakWithUserMetadata = async ( params: StreakWithMetadataParams, ): Promise<{ data: StreakWithUserMetadata }> => privateFetcher(makeStreakUrl(params)); diff --git a/src/utils/auth/apiPaths.ts b/src/utils/auth/apiPaths.ts index 297d9c1f8d..c7faec4b3f 100644 --- a/src/utils/auth/apiPaths.ts +++ b/src/utils/auth/apiPaths.ts @@ -110,6 +110,9 @@ export const makeActivityDaysUrl = (params: { mushafId?: Mushaf; type: ActivityD export const makeFilterActivityDaysUrl = (params: FilterActivityDaysParams) => makeUrl('activity-days/filter', params); +export const makeEstimateRangesReadingTimeUrl = (params: { ranges: string[] }) => + makeUrl('activity-days/estimate-reading-time', { ranges: params.ranges.join(',') }); + export const makeGoalUrl = (params: { mushafId?: Mushaf; type: GoalCategory }) => makeUrl('goal', params); diff --git a/src/utils/datetime.ts b/src/utils/datetime.ts index aac2b1f3b7..2822044260 100644 --- a/src/utils/datetime.ts +++ b/src/utils/datetime.ts @@ -55,7 +55,8 @@ export const secondsToReadableFormat = (s: number, t: Translate, locale: string) seconds %= 60; } - if (seconds > 0) { + // if there are seconds left, or if the duration is 0 (in this case, `results.length` = 0), add seconds + if (seconds > 0 || results.length === 0) { results.push( t('reading-goal:x-seconds', { count: seconds, @@ -153,17 +154,33 @@ export const getTimezone = (): string => { }; /** - * Converts a date instance to a string in this format: YYYY-MM-DD + * Given a Date, return the year, month and day values * * @param {Date} date - * @returns {string} + * @returns {{year: number, month: number, day: number}} */ -export const dateToDateString = (date: Date): string => { +export const dateToYearMonthDay = (date: Date): { year: number; month: number; day: number } => { const year = date.getFullYear(); const month = date.getMonth() + 1; const day = date.getDate(); + return { year, month, day }; +}; + +export const getCurrentMonth = () => new Date().getMonth() + 1; + +export const getCurrentDay = () => new Date().getDate(); - return `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`; +/** + * Converts a date instance to a string in this format: YYYY-MM-DD + * + * @param {Date} date + * @returns {string} + */ +export const dateToDateString = ( + date: Date | { day: number; month: number; year: number }, +): string => { + const { year, month, day } = date instanceof Date ? dateToYearMonthDay(date) : date; + return `${year}-${numberToPaddedString(month)}-${numberToPaddedString(day)}`; }; /** @@ -176,7 +193,7 @@ export const dateToDateString = (date: Date): string => { * */ export const getFullDayName = (day: Date, locale: string): string => { - return day.toLocaleDateString(locale, { weekday: 'long' }); + return day.toLocaleDateString(locale, { weekday: 'long', timeZone: 'UTC' }); }; /** @@ -189,7 +206,7 @@ export const getFullDayName = (day: Date, locale: string): string => { * */ export const getFullMonthName = (month: Date, locale: string): string => { - return month.toLocaleDateString(locale, { month: 'long' }); + return month.toLocaleDateString(locale, { month: 'long', timeZone: 'UTC' }); }; /** @@ -219,6 +236,16 @@ export const dateToReadableFormat = ( }); }; +/** + * Convert a number into a padded string with 0. E.g. 1 -> 01 + * + * @param {number} number + * @returns {string} + */ +export const numberToPaddedString = (number: number): string => { + return number.toString().padStart(2, '0'); +}; + type DateRange = { from: string; to: string }; /** @@ -229,11 +256,43 @@ type DateRange = { from: string; to: string }; * @returns {DateRange} */ export const makeDateRangeFromMonth = (month: number, year: number): DateRange => { - const from = `${year}-${month.toString().padStart(2, '0')}-01`; - const to = `${year}-${month.toString().padStart(2, '0')}-${new Date(year, month, 0) - .getDate() - .toString() - .padStart(2, '0')}`; + const from = `${year}-${numberToPaddedString(month)}-01`; + const to = `${year}-${numberToPaddedString(month)}-${numberToPaddedString( + new Date(year, month, 0).getDate(), + )}`; return { from, to }; }; + +type Month = { id: number; name: string; daysCount: number }; + +/** + * Get a Date out of year and month numbers. + * + * @param {year} year + * @param {month} month + * @returns {Date} + */ +export const getMonthDateObject = (year: number, month: number): Date => { + return new Date(year, month, 0); +}; + +/** + * This function returns an array of months in a given year. + * + * @param {number} year + * @param {string} locale + * @returns {Month[]} + */ +export const getMonthsInYear = (year: number, locale: string): Month[] => { + const all: Month[] = []; + + for (let i = 1; i <= 12; i += 1) { + const monthDate = getMonthDateObject(year, i); + const daysInMonth = monthDate.getDate(); + + all.push({ id: i, name: getFullMonthName(monthDate, locale), daysCount: daysInMonth }); + } + + return all; +}; diff --git a/types/auth/ActivityDay.ts b/types/auth/ActivityDay.ts index 62d0231c2f..0717fe577f 100644 --- a/types/auth/ActivityDay.ts +++ b/types/auth/ActivityDay.ts @@ -10,6 +10,7 @@ export type ActivityDay = { pagesRead: number; versesRead: number; secondsRead: number; + manuallyAddedSeconds?: number; progress: number; dailyTargetPages?: number; dailyTargetSeconds?: number; @@ -21,6 +22,7 @@ export type ActivityDay = { export type UpdateActivityDayBody = { ranges?: string[]; pages?: number; + date?: string; seconds?: number; mushafId: Mushaf; type: ActivityDayType;