Repository: zestedesavoir/zds-site

Issue:
L'estimation du temps de lecture est ambigü
**Description du bug**

Lorsqu'on a un tutoriel (un big tuto, la plupart du temps), qui est assez long, on affiche l'estimation du temps de lecture comme "1jour 6heures" par exemple. Le problème c'est que pour le lecteur lambda "1jour" c'est un peu ambigü : est-ce une journée de travail normal (7h)? une journée astronomique (24h?) une journée "à la louche" (du réveil au couché)?

Techniquement nous avons simplement besoin de limiter notre conversion aux heures : dans le cas précédent 30h de lecture, pour un big tuto qui fait 600pages en PDF c'est une estimation normale (une page toutes les trois minutes sachant qu'on demande aux gens de tester etc. c'est plutôt acceptable) alors que 1jour 6h est ambigüe.

**Comment reproduire ?**

La liste des étapes qui permet de reproduire le bug :

1. Prendre l'archive du tutoriel arduino
2. Le publier
3. Regarder l'estimation du temps de lecture
4. Constatez que c'est 1 jour 6h

**Comportement attendu**

L'estimation est de 30h.

**Informations techniques**

- Version/environnement de zds: versions 29.1 ou inférieures



Discussion:
Petit guide pour intervenir sur ce bug:

L'affichage de la durée est calculée à la ligne 118 du fichier `templates/tutorialv2/includes/tags_authors.part.html`

```django
 {% if online and reading_time and not is_part_or_chapter %}
            {% trans "Temps de lecture estimé : " %}{{ reading_time|minute_to_duration }}
        {% elif online and not is_part_or_chapter %}
            {% trans "Temps de lecture estimé à moins d’une minute." %}
        {% endif %}
```
La fonction qui est utilisée est `minute_to_duration` qui est définie dans `zds.utils.templatetags.minute_to_duration`.

On remarque que cette fonction appelle un filtre django appelé timesince. C'est ce dernier qui fait le calcul et impose que nous ayons forcément la journée.

L'idée serait de se passer de cette méthode pour n'afficher que le nombre de minutes et d'heures sauf si value > 24 * 60 où on ne garde que les heures.
Salut ! J'aimerai m'occuper de cette issue, je peux ou est ce que c'est plus pour des membres qui commencent vraiment ?
Tu peux, fais toi plaiz
> Salut ! J'aimerai m'occuper de cette issue, je peux ou est ce que c'est plus pour des membres qui commencent vraiment ?

@ToxicScorpius Souhaites-tu toujours travailler dessus ? Je demande pour savoir si je peux t'attribuer la tâche. Ne t'en fais pas, si tu ne peux plus ou si tu ne veux plus, on peut supprimer l'attribution !
> On remarque que cette fonction appelle un filtre django appelé timesince. C'est ce dernier qui fait le calcul et impose que nous ayons forcément la journée.
> 
> L'idée serait de se passer de cette méthode pour n'afficher que le nombre de minutes et d'heures sauf si value > 24 * 60 où on ne garde que les heures.

Faut-il créer un nouveau filtre personnalisé ?
ça peut être une solution, en effet;
L'idée du filtre a été écartée car trop complexe pour rien.

Par ailleurs, il faudra réécrire [son test](https://github.com/zestedesavoir/zds-site/blob/42cb8ef05cb1ff047d0ad3839d8ad48a18a472e2/zds/utils/tests/tests_minute_to_duration.py).
Je finalise le truc ou on laisse tomber ?
C'est toujours ouvert donc il est encore temps de finaliser. :-)
Je suis confus, elle est toujours ouverte ?
oui car la PR de toxic n'a pas été mergée

Pour info, le travail de Toxic est en attente de reprise, donc n'importe qui peut reprendre ce sujet, quitte à s'inspirer de son travail.

Resolving patch:
diff --git a/zds/utils/templatetags/humanize_duration.py b/zds/utils/templatetags/humanize_duration.py
new file mode 100644
--- /dev/null
+++ b/zds/utils/templatetags/humanize_duration.py
@@ -0,0 +1,62 @@
+from typing import Tuple, List, Iterator, Iterable
+
+from django import template
+from django.utils.translation import ngettext, gettext as _
+
+register = template.Library()
+
+
+@register.filter
+def humanize_duration(duration_min: int) -> str:
+    """
+    Display a human-readable duration.
+    The granularity gets larger as the value gets larger to avoid meaningless precision.
+
+    See the unit tests for examples of results.
+    """
+    duration_min = max(duration_min, 0)  # to avoid surprises with modulo operations
+    truncated_value = _truncate_duration(
+        duration_min,
+        bounds=[600, 120, 60, 30, 15],
+        precisions=[60, 30, 15, 10, 5],
+    )
+    hours, minutes = _minutes_to_hours_and_minutes(truncated_value)
+    return _display_hours_and_minutes(hours, minutes)
+
+
+def _truncate_duration(duration: int, bounds: Iterable[int], precisions: Iterable[int]) -> int:
+    """
+    Truncate a duration to lower its precision.
+    Precisions are given for a set of intervals delimited by the given lower bounds.
+    The duration, precisions and bounds are expressed in the same unit (e.g. minutes).
+    """
+    for bound, precision in zip(bounds, precisions):
+        if duration >= bound:
+            return duration - (duration % precision)
+    return duration
+
+
+def _minutes_to_hours_and_minutes(duration_min: int) -> Tuple[int, int]:
+    """
+    Convert a duration expressed in minutes to a duration expressed in hours and minutes.
+    `duration_min` shall be positive to ensure a correct behavior.
+    """
+    minutes_per_hour = 60
+    hours, minutes = divmod(duration_min, minutes_per_hour)
+    return hours, minutes
+
+
+def _display_hours_and_minutes(hours: int, minutes: int) -> str:
+    """Display a human-readable duration given in hours and minutes."""
+
+    minutes_fragment = ngettext("1 minute", "{minutes} minutes", minutes).format(minutes=minutes)
+    hours_fragment = ngettext("1 heure", "{hours} heures", hours).format(hours=hours)
+
+    if hours == 0 and minutes == 0:
+        return _("moins d'une minute")
+    elif hours == 0:
+        return minutes_fragment
+    elif minutes == 0:
+        return hours_fragment
+    else:
+        return _("{hours} et {minutes}").format(hours=hours_fragment, minutes=minutes_fragment)
diff --git a/zds/utils/templatetags/minute_to_duration.py b/zds/utils/templatetags/minute_to_duration.py
deleted file mode 100644
--- a/zds/utils/templatetags/minute_to_duration.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from django import template
-from django.template import defaultfilters as filters
-from django.utils.translation import gettext as _
-import datetime
-
-register = template.Library()
-
-
-@register.filter("minute_to_duration")
-def minute_to_duration(value):
-    """
-    Display a human-readable reading-time (or any other duration)
-    from a duration in minutes, with a granularity of 15 minutes.
-    """
-    now = datetime.datetime.now()
-    if value <= 0:
-        return ""
-    # Rounds value to avoid "1 hour, 2 minutes".
-    if 55 <= value <= 65:
-        value = 60
-    elif value > 65:
-        value = value - (value % 15)
-    delta = now - datetime.timedelta(minutes=value)
-    return filters.timesince(delta).replace(", ", _(" et "))


Test patch:
diff --git a/zds/utils/tests/tests_humanize_duration.py b/zds/utils/tests/tests_humanize_duration.py
new file mode 100644
--- /dev/null
+++ b/zds/utils/tests/tests_humanize_duration.py
@@ -0,0 +1,26 @@
+from django.test import TestCase
+from zds.utils.templatetags.humanize_duration import humanize_duration
+
+
+class TemplateTagsTest(TestCase):
+    def test_main(self):
+        test_cases = [
+            {"input": -1, "expected": "moins d'une minute"},
+            {"input": 0, "expected": "moins d'une minute"},
+            {"input": 1, "expected": "1 minute"},
+            {"input": 2, "expected": "2 minutes"},
+            {"input": 59, "expected": "50 minutes"},
+            {"input": 60, "expected": "1 heure"},
+            {"input": 72, "expected": "1 heure"},
+            {"input": 90, "expected": "1 heure et 30 minutes"},
+            {"input": 105, "expected": "1 heure et 45 minutes"},
+            {"input": 110, "expected": "1 heure et 45 minutes"},
+            {"input": 125, "expected": "2 heures"},
+            {"input": 155, "expected": "2 heures et 30 minutes"},
+            {"input": 24 * 60, "expected": "24 heures"},
+            {"input": 48 * 60, "expected": "48 heures"},
+        ]
+
+        for test_case in test_cases:
+            with self.subTest(f"case: {test_case['input']} min"):
+                self.assertEqual(humanize_duration(test_case["input"]), test_case["expected"])
diff --git a/zds/utils/tests/tests_minute_to_duration.py b/zds/utils/tests/tests_minute_to_duration.py
deleted file mode 100644
--- a/zds/utils/tests/tests_minute_to_duration.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from django.test import TestCase
-from zds.utils.templatetags.minute_to_duration import minute_to_duration
-
-
-class TemplateTagsTest(TestCase):
-    def test_main(self):
-        self.assertEqual(minute_to_duration(0), "")
-        self.assertEqual(minute_to_duration(-1), "")
-        self.assertEqual(minute_to_duration(1), "1 minute")
-        self.assertEqual(minute_to_duration(2), "2 minutes")
-        self.assertEqual(minute_to_duration(59), "1 heure")
-        self.assertEqual(minute_to_duration(60), "1 heure")
-        self.assertEqual(minute_to_duration(72), "1 heure")
-        self.assertEqual(minute_to_duration(90), "1 heure et 30 minutes")
-        self.assertEqual(minute_to_duration(105), "1 heure et 45 minutes")
-        self.assertEqual(minute_to_duration(110), "1 heure et 45 minutes")