diff --git a/app/scodoc/sco_evaluations.py b/app/scodoc/sco_evaluations.py
index ef2e8526b..ee11aa6c6 100644
--- a/app/scodoc/sco_evaluations.py
+++ b/app/scodoc/sco_evaluations.py
@@ -25,8 +25,8 @@
 #
 ##############################################################################
 
-"""Evaluations
-"""
+"""Evaluations"""
+
 import collections
 import datetime
 import operator
@@ -50,6 +50,7 @@ from app.scodoc import sco_cal
 from app.scodoc import sco_evaluation_db
 from app.scodoc import sco_formsemestre_inscriptions
 from app.scodoc import sco_groups
+from app.scodoc import sco_gen_cal
 from app.scodoc import sco_moduleimpl
 from app.scodoc import sco_preferences
 from app.scodoc import sco_users
@@ -360,6 +361,103 @@ def do_evaluation_etat_in_mod(nt, modimpl: ModuleImpl):
     return etat
 
 
+class JourEval(sco_gen_cal.Jour):
+    """
+    Représentation d'un jour dans un calendrier d'évaluations
+    """
+
+    COLOR_INCOMPLETE = "#FF6060"
+    COLOR_COMPLETE = "#A0FFA0"
+    COLOR_FUTUR = "#70E0FF"
+
+    def __init__(
+        self,
+        date: datetime.date,
+        evaluations: list[Evaluation],
+        parent: "CalendrierEval",
+    ):
+        super().__init__(date)
+
+        self.evaluations: list[Evaluation] = evaluations
+        self.evaluations.sort(key=lambda e: e.date_debut)
+
+        self.parent: "CalendrierEval" = parent
+
+    def get_html(self) -> str:
+        html: str = ""
+
+        for e in self.evaluations:
+            url: str = url_for(
+                "notes.moduleimpl_status",
+                scodoc_dept=g.scodoc_dept,
+                moduleimpl_id=e.moduleimpl_id,
+            )
+            title: str = (
+                e.moduleimpl.module.code or e.moduleimpl.module.abbrev or "éval."
+            )
+            html += f"""<a
+                href="{url}"
+                style="{self._get_eval_style(e)}"
+                title="{self._get_eval_title(e)}"
+            >
+            {title}</a><br>"""
+
+        return html
+
+    def _get_eval_style(self, e: Evaluation) -> str:
+        color: str = ""
+        # Etat (notes completes) de l'évaluation:
+        modimpl_result = self.parent.nt.modimpls_results[e.moduleimpl.id]
+        if modimpl_result.evaluations_etat[e.id].is_complete:
+            color = JourEval.COLOR_COMPLETE
+        else:
+            color = JourEval.COLOR_INCOMPLETE
+        if e.date_debut > datetime.datetime.now(scu.TIME_ZONE):
+            color = JourEval.COLOR_FUTUR
+
+        return f"background-color: {color};"
+
+    def _get_eval_title(self, e: Evaluation) -> str:
+        heure_debut_txt, heure_fin_txt = "", ""
+        if e.date_debut != e.date_fin:
+            heure_debut_txt = (
+                e.date_debut.strftime(scu.TIME_FMT) if e.date_debut else ""
+            )
+            heure_fin_txt = e.date_fin.strftime(scu.TIME_FMT) if e.date_fin else ""
+
+        title = f"{e.description or e.moduleimpl.module.titre_str()}"
+        if heure_debut_txt:
+            title += f" de {heure_debut_txt} à {heure_fin_txt}"
+
+        return title
+
+
+class CalendrierEval(sco_gen_cal.Calendrier):
+    """
+    Représentation des évaluations d'un semestre dans un calendrier
+    """
+
+    def __init__(self, year: int, evals: list[Evaluation], nt: NotesTableCompat):
+        # On prend du 01/09 au 31/08
+        date_debut: datetime.datetime = datetime.datetime(year, 9, 1, 0, 0)
+        date_fin: datetime.datetime = datetime.datetime(year + 1, 8, 31, 23, 59)
+        super().__init__(date_debut, date_fin)
+
+        # évalutions du semestre
+        self.evals: dict[datetime.date, list[Evaluation]] = {}
+        for e in evals:
+            if e.date_debut is not None:
+                day = e.date_debut.date()
+                if day not in self.evals:
+                    self.evals[day] = []
+                self.evals[day].append(e)
+
+        self.nt: NotesTableCompat = nt
+
+    def instanciate_jour(self, date: datetime.date) -> JourEval:
+        return JourEval(date, self.evals.get(date, []), parent=self)
+
+
 # View
 def formsemestre_evaluations_cal(formsemestre_id):
     """Page avec calendrier de toutes les evaluations de ce semestre"""
@@ -369,52 +467,9 @@ def formsemestre_evaluations_cal(formsemestre_id):
     evaluations = formsemestre.get_evaluations()
     nb_evals = len(evaluations)
 
-    color_incomplete = "#FF6060"
-    color_complete = "#A0FFA0"
-    color_futur = "#70E0FF"
-
     year = formsemestre.annee_scolaire()
-    events_by_day = collections.defaultdict(list)  # date_iso : event
-    for e in evaluations:
-        if e.date_debut is None:
-            continue  # éval. sans date
-        if e.date_debut == e.date_fin:
-            heure_debut_txt, heure_fin_txt = "", ""
-        else:
-            heure_debut_txt = (
-                e.date_debut.strftime(scu.TIME_FMT) if e.date_debut else ""
-            )
-            heure_fin_txt = e.date_fin.strftime(scu.TIME_FMT) if e.date_fin else ""
-
-        # Etat (notes completes) de l'évaluation:
-        modimpl_result = nt.modimpls_results[e.moduleimpl.id]
-        if modimpl_result.evaluations_etat[e.id].is_complete:
-            color = color_complete
-        else:
-            color = color_incomplete
-        if e.date_debut > datetime.datetime.now(scu.TIME_ZONE):
-            color = color_futur
-        day = e.date_debut.date().isoformat()  # yyyy-mm-dd
-        event = {
-            "color": color,
-            "date_iso": day,
-            "title": e.moduleimpl.module.code or e.moduleimpl.module.abbrev or "éval.",
-            "description": f"""{e.description or e.moduleimpl.module.titre_str()}"""
-            + (
-                f""" de {heure_debut_txt} à {heure_fin_txt}"""
-                if heure_debut_txt
-                else ""
-            ),
-            "href": url_for(
-                "notes.moduleimpl_status",
-                scodoc_dept=g.scodoc_dept,
-                moduleimpl_id=e.moduleimpl_id,
-            ),
-            "modimpl": e.moduleimpl,
-        }
-        events_by_day[day].append(event)
-
-    cal_html = sco_cal.YearTable(year, events_by_day=events_by_day)
+    cal = CalendrierEval(year, evaluations, nt)
+    cal_html = cal.get_html()
 
     return f"""
     {
@@ -430,15 +485,15 @@ def formsemestre_evaluations_cal(formsemestre_id):
     </p>
     <ul>
         <li>en <span style=
-            "background-color: {color_incomplete}">rouge</span>
+            "background-color: {JourEval.COLOR_INCOMPLETE}">rouge</span>
         les évaluations passées auxquelles il manque des notes
         </li>
         <li>en <span style=
-            "background-color: {color_complete}">vert</span>
+            "background-color: {JourEval.COLOR_COMPLETE}">vert</span>
         les évaluations déjà notées
         </li>
         <li>en <span style=
-            "background-color: {color_futur}">bleu</span>
+            "background-color: {JourEval.COLOR_FUTUR}">bleu</span>
         les évaluations futures
         </li>
     </ul>
diff --git a/app/scodoc/sco_gen_cal.py b/app/scodoc/sco_gen_cal.py
new file mode 100644
index 000000000..54a66e9de
--- /dev/null
+++ b/app/scodoc/sco_gen_cal.py
@@ -0,0 +1,153 @@
+"""
+Génération d'un calendrier
+(Classe abstraite à implémenter dans les classes filles)
+"""
+
+import datetime
+from flask import render_template
+
+import app.scodoc.sco_utils as scu
+from app import g
+
+
+class Jour:
+    """
+    Représente un jour dans le calendrier
+    Permet d'obtenir les informations sur le jour
+    et générer une représentation html
+    """
+
+    def __init__(self, date: datetime.date):
+        self.date = date
+        self.class_list: list[str] = []
+
+        if self.is_non_work():
+            self.class_list.append("non-travail")
+        if self.is_current_week():
+            self.class_list.append("sem-courante")
+
+    def get_nom(self, short=True):
+        """
+        Renvoie le nom du jour
+        "M19" ou "Mer 19"
+
+        par défaut en version courte
+        """
+        str_jour: str = scu.DAY_NAMES[self.date.weekday()].capitalize()
+        return (
+            f"{str_jour[0] if short or self.is_non_work() else str_jour[:3]+' '}"
+            + f"{self.date.day}"
+        )
+
+    def is_non_work(self):
+        """
+        Renvoie True si le jour est un jour non travaillé
+        (en fonction de la préférence du département)
+        """
+        return self.date.weekday() in scu.NonWorkDays.get_all_non_work_days(
+            dept_id=g.scodoc_dept_id
+        )
+
+    def is_current_week(self):
+        """
+        Renvoie True si le jour est dans la semaine courante
+        """
+        return self.date.isocalendar()[0:2] == datetime.date.today().isocalendar()[0:2]
+
+    def get_date(self) -> str:
+        """
+        Renvoie la date du jour au format "dd/mm/yyyy"
+        """
+        return self.date.strftime(scu.DATE_FMT)
+
+    def get_html(self):
+        """
+        Renvoie le code html du jour
+        à surcharger dans les classes filles
+
+        l'html final ressemblera à :
+
+        <div class="jour {{jour.get_class()}}">
+            <span class="nom">{{jour.get_nom()}}</span>
+            <div class="contenu">
+                {{jour.get_html() | safe}}
+            </div>
+        </div>
+
+        """
+        raise NotImplementedError("Méthode à implémenter dans les classes filles")
+
+    def get_class(self):
+        """
+        Renvoie la classe css du jour
+
+        utilise self.class_list
+        -> fait un join de la liste
+
+        """
+        return " ".join(self.class_list)
+
+
+class Calendrier:
+    """
+    Représente un calendrier
+    Permet d'obtenir les informations sur les jours
+    et générer une représentation html
+    """
+
+    def __init__(self, date_debut: datetime.date, date_fin: datetime.date):
+        self.date_debut = date_debut
+        self.date_fin = date_fin
+        self.jours: dict[str, list[Jour]] = {}
+
+    def _get_dates_between(self) -> list[datetime.date]:
+        """
+        get_dates_between Renvoie la liste des dates entre date_debut et date_fin
+
+        Returns:
+            list[datetime.date]: liste des dates entre date_debut et date_fin
+        """
+        resultat = []
+        date_actuelle: datetime.date = self.date_debut
+        while date_actuelle <= self.date_fin:
+            if isinstance(date_actuelle, datetime.datetime):
+                resultat.append(date_actuelle.date())
+            elif isinstance(date_actuelle, datetime.date):
+                resultat.append(date_actuelle)
+            date_actuelle += datetime.timedelta(days=1)
+        return resultat
+
+    def organize_by_month(self):
+        """
+        Organise les jours par mois
+        Instancie un objet Jour pour chaque jour
+
+        met à jour self.jours
+        """
+        organized = {}
+        for date in self._get_dates_between():
+            # Récupérer le mois en français
+            month = scu.MONTH_NAMES_ABBREV[date.month - 1]
+            # Ajouter le jour à la liste correspondante au mois
+            if month not in organized:
+                organized[month] = []
+
+            jour: Jour = self.instanciate_jour(date)
+
+            organized[month].append(jour)
+
+        self.jours = organized
+
+    def instanciate_jour(self, date: datetime.date) -> Jour:
+        """
+        Instancie un objet Jour pour chaque jour
+        A surcharger dans les classes filles si besoin
+        """
+        raise NotImplementedError("Méthode à implémenter dans les classes filles")
+
+    def get_html(self):
+        """
+        get_html Renvoie le code html du calendrier
+        """
+        self.organize_by_month()
+        return render_template("calendrier.j2", calendrier=self.jours)
diff --git a/app/static/css/minitimeline.css b/app/static/css/minitimeline.css
index 3c7347002..b1707f667 100644
--- a/app/static/css/minitimeline.css
+++ b/app/static/css/minitimeline.css
@@ -1,19 +1,21 @@
-.day .dayline {
+.jour .dayline {
     position: absolute;
     display: none;
     top: 100%;
     z-index: 50;
     width: max-content;
-    height: 75px;
     background-color: #dedede;
-    border-radius: 15px;
-    padding: 5px;
+    border-radius: 8px;
+    padding: 5px 5px 15px 5px;
+    transform: translateX(-50%);
+    border: 2px solid #333;
 }
 
-.day:hover .dayline {
+.jour:hover .dayline {
     display: block;
 }
 
+
 .dayline .mini-timeline {
     margin-top: 10%;
 }
diff --git a/app/templates/assiduites/pages/calendrier_assi_etud.j2 b/app/templates/assiduites/pages/calendrier_assi_etud.j2
index a3f2c93ec..d78222944 100644
--- a/app/templates/assiduites/pages/calendrier_assi_etud.j2
+++ b/app/templates/assiduites/pages/calendrier_assi_etud.j2
@@ -23,47 +23,8 @@ Calendrier de l'assiduité
             for="mode_demi">mode demi journée</label>
     </div>
 
-    <div class="calendrier">
-        {% for mois,jours in calendrier.items() %}
-        <div class="month">
-            <h3>{{mois}}</h3>
-            <div class="days {{'demi' if mode_demi else ''}}">
-                {% for jour in jours %}
-                {% if jour.is_non_work() %}
-                <div class="day {{jour.get_class()}}">
-                    <span>{{jour.get_nom()}}</span>
-                    {% else %}
-                    <div class="day {{jour.get_class(show_pres, show_reta) if not mode_demi else ''}}">
-                        {% endif %}
-                        {% if mode_demi %}
-                        {% if not jour.is_non_work() %}
-                        <span>{{jour.get_nom()}}</span>
-                        <span class="{{jour.get_demi_class(True, show_pres,show_reta)}}"></span>
-                        <span class="{{jour.get_demi_class(False, show_pres,show_reta)}}"></span>
-                        {% endif %}
-                        {% else %}
-                        {% if not jour.is_non_work() %}
-                        <span>{{jour.get_nom(False)}}</span>
-                        {% endif %}
-                        {% endif %}
-
-                        {% if not jour.is_non_work() and jour.has_assiduites()%}
-
-                        <div class="dayline">
-                            <div class="dayline-title">
-                                <span>{{jour.get_date()}}</span>
-                                {{jour.generate_minitimeline() | safe}}
-                            </div>
-                        </div>
-
-                        {% endif %}
-                    </div>
-
-                    {% endfor %}
-                </div>
-            </div>
-            {% endfor %}
-        </div>
+    <div class="cal">
+        {{calendrier|safe}}
         <div class="annee">
             <span id="label-annee">Année scolaire</span><span id="label-changer" style="margin-left: 5px;">Changer
                 année: </span>
@@ -134,36 +95,26 @@ Calendrier de l'assiduité
             margin-bottom: 12px;
         }
 
-        .month h3 {
-            text-align: center;
-        }
-
-        .day,
-        .demi .day.color.nonwork {
-            text-align: left;
-            margin: 2px;
-            cursor: default;
-            font-size: 13px;
-            position: relative;
-            font-weight: normal;
-            min-width: 6em;
+        .assi_case {
             display: flex;
-            justify-content: start;
+            width: 100%;
+            height: 100%;
         }
 
-        
-        
-
-
-        .demi .day.nonwork>span {
-            flex: none;
-            border: none;
+        .assi_case > span {
+            flex: 1;
         }
 
-        .demi .day {
-            border-radius: 0;
+        .assi_case>span:last-of-type {
+            border-left: #d5d5d5 solid 1px;
+        }
+        .assi_case>span:first-of-type {
+            border-right: #d5d5d5 solid 1px;
         }
 
+        .dayline{
+            display: none;
+        }
 
         @media print {
 
@@ -294,7 +245,5 @@ Calendrier de l'assiduité
                 window.open(`${SCO_URL}Assiduites/tableau_assiduite_actions?type=assiduite&action=details&obj_id=${assi_id}`);
             })
         });
-
-
     </script>
     {% endblock app_content %}
\ No newline at end of file
diff --git a/app/templates/calendrier.j2 b/app/templates/calendrier.j2
new file mode 100644
index 000000000..ce13a4f81
--- /dev/null
+++ b/app/templates/calendrier.j2
@@ -0,0 +1,79 @@
+<div class="calendrier">
+    {% for mois,jours in calendrier.items() %}
+    <div class="mois">
+        <h3>{{mois}}</h3>
+        <div class="jours">
+            {% for jour in jours %}
+            <div class="jour {{jour.get_class()}}">
+                <span class="nom">{{jour.get_nom()}}</span>
+                <div class="contenu">
+                    {{jour.get_html() | safe}}
+                </div>
+            </div>
+            {% endfor %}
+        </div>
+    </div>
+    {% endfor %}
+</div>
+
+<style>
+    .calendrier {
+        display: flex;
+        justify-content: center;
+        overflow-x: scroll;
+        border: 1px solid #444;
+        border-radius: 12px;
+        margin-bottom: 12px;
+    }
+
+    .mois h3 {
+        text-align: center;
+    }
+
+    .jour {
+        text-align: left;
+        margin: 2px;
+        cursor: default;
+        font-size: 13px;
+        position: relative;
+        font-weight: normal;
+        min-width: 6em;
+        display: flex;
+        justify-content: start;
+    }
+
+    .jour>.contenu {
+        background-color: white;
+        width: 100%;
+    }
+
+    .jour.jour.non-travail>.nom,
+    .jour.jour.non-travail>.contenu {
+        border: 0;
+        background-color: #badfff;
+    }
+
+    .jour>.nom {
+        width: 3em;
+        min-width: 3em;
+
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        flex-direction: column;
+    }
+
+    .jour>.contenu,
+    .jour>.nom {
+        text-align: center;
+        border: 1px solid #d5d5d5;
+        position: relative;
+    }
+
+    .sem-courante{
+        --couleur : #ee752c;
+        border-left: solid 3px var(--couleur);
+        border-right: solid 3px var(--couleur);
+    }
+
+</style>
\ No newline at end of file
diff --git a/app/views/assiduites.py b/app/views/assiduites.py
index 82990fcbc..e9a32454c 100644
--- a/app/views/assiduites.py
+++ b/app/views/assiduites.py
@@ -63,6 +63,7 @@ from app.models import (
     Scolog,
 )
 from app.scodoc.codes_cursus import UE_STANDARD
+
 from app.auth.models import User
 from app.models.assiduites import get_assiduites_justif
 from app.tables.list_etuds import RowEtud, TableEtud
@@ -82,6 +83,7 @@ from app.scodoc import sco_find_etud
 from app.scodoc import sco_assiduites as scass
 from app.scodoc import sco_utils as scu
 from app.scodoc.sco_exceptions import ScoValueError
+from app.scodoc import sco_gen_cal
 
 
 from app.tables.visu_assiduites import TableAssi, etuds_sorted_from_ids
@@ -757,8 +759,6 @@ def _verif_date_form_justif(
         deb = deb.replace(hour=0, minute=0)
         fin = fin.replace(hour=23, minute=59)
 
-    print(f"DEBUG {cas=}")
-
     return deb, fin
 
 
@@ -925,7 +925,14 @@ def calendrier_assi_etud():
     # (sera utilisé pour générer le selecteur d'année)
     annees_str: str = json.dumps(annees)
 
-    calendrier: dict[str, list["Jour"]] = generate_calendar(etud, annee)
+    cal = CalendrierAssi(
+        annee,
+        etud,
+        mode_demi=mode_demi,
+        show_pres=show_pres,
+        show_reta=show_reta,
+    )
+    calendrier: str = cal.get_html()
 
     # Peuplement du template jinja
     return render_template(
@@ -2517,79 +2524,74 @@ def _get_etuds_dem_def(formsemestre) -> str:
 # --- Gestion du calendrier ---
 
 
-def generate_calendar(
-    etudiant: Identite,
-    annee: int = None,
-) -> dict[str, list["Jour"]]:
+class JourAssi(sco_gen_cal.Jour):
     """
-    Génère le calendrier d'assiduité de l'étudiant pour une année scolaire donnée
-    """
-    # Si pas d'année alors on prend l'année scolaire en cours
-    if annee is None:
-        annee = scu.annee_scolaire()
-
-    # On prend du 01/09 au 31/08
-    date_debut: datetime.datetime = datetime.datetime(annee, 9, 1, 0, 0)
-    date_fin: datetime.datetime = datetime.datetime(annee + 1, 8, 31, 23, 59)
-
-    # Filtrage des assiduités et des justificatifs en fonction de la periode / année
-    etud_assiduites: Query = scass.filter_by_date(
-        etudiant.assiduites,
-        Assiduite,
-        date_deb=date_debut,
-        date_fin=date_fin,
-    )
-    etud_justificatifs: Query = scass.filter_by_date(
-        etudiant.justificatifs,
-        Justificatif,
-        date_deb=date_debut,
-        date_fin=date_fin,
-    )
-
-    # Récupération des jours de l'année et de leurs assiduités/justificatifs
-    annee_par_mois: dict[str, list[Jour]] = _organize_by_month(
-        _get_dates_between(
-            deb=date_debut.date(),
-            fin=date_fin.date(),
-        ),
-        etud_assiduites,
-        etud_justificatifs,
-    )
-
-    return annee_par_mois
-
-
-class Jour:
-    """Jour
-    Jour du calendrier
-    get_nom : retourne le numéro et le nom du Jour (ex: M19 / Mer 19)
+    Représente un jour d'assiduité
     """
 
-    def __init__(self, date: datetime.date, assiduites: Query, justificatifs: Query):
-        self.date = date
+    def __init__(
+        self,
+        date: datetime.date,
+        assiduites: Query,
+        justificatifs: Query,
+        parent: "CalendrierAssi",
+    ):
+        super().__init__(date)
+
+        # assiduités et justificatifs du jour
         self.assiduites = assiduites
         self.justificatifs = justificatifs
 
-    def get_nom(self, mode_demi: bool = True) -> str:
-        """
-        Renvoie le nom du jour
-        "M19" ou "Mer 19"
-        """
-        str_jour: str = scu.DAY_NAMES[self.date.weekday()].capitalize()
-        return (
-            f"{str_jour[0] if mode_demi or self.is_non_work() else str_jour[:3]+' '}"
-            + f"{self.date.day}"
+        self.parent = parent
+
+    def get_html(self) -> str:
+        # si non travaillé on renvoie une case vide
+        if self.is_non_work():
+            return ""
+
+        html: str = (
+            self._get_html_demi() if self.parent.mode_demi else self._get_html_normal()
         )
+        html = f'<div class="assi_case">{html}</div>'
 
-    def get_date(self) -> str:
-        """
-        Renvoie la date du jour au format "dd/mm/yyyy"
-        """
-        return self.date.strftime(scu.DATE_FMT)
+        if self.has_assiduite():
+            minitimeline: str = f"""
+            <div class="dayline">
+                <div class="dayline-title">
+                    <span>{self.get_date()}</span>
+                    {self._generate_minitimeline()}
+                </div>
+            </div>
+            """
+            html += minitimeline
 
-    def get_class(self, show_pres: bool = False, show_reta: bool = False) -> str:
+        return html
+
+    def has_assiduite(self) -> bool:
+        """Renvoie True si le jour a une assiduité"""
+        return self.assiduites.count() > 0
+
+    def _get_html_normal(self) -> str:
         """
-        Retourne la classe css du jour (mode normal)
+        Renvoie l'html de la case du calendrier
+        (version journee normale (donc une couleur))
+        """
+        class_name = self._get_color_normal()
+        return f'<span class="{class_name}"></span>'
+
+    def _get_html_demi(self) -> str:
+        """
+        Renvoie l'html de la case du calendrier
+        (version journee divisée en demi-journées (donc 2 couleurs))
+        """
+        matin = self._get_color_demi(True)
+        aprem = self._get_color_demi(False)
+        return f'<span class="{matin}"></span><span class="{aprem}"></span>'
+
+    def _get_color_normal(self) -> str:
+        """renvoie la classe css correspondant
+        à la case du calendrier
+        (version journee normale)
         """
         etat = ""
         est_just = ""
@@ -2599,8 +2601,8 @@ class Jour:
 
         etat = self._get_color_assiduites_cascade(
             self._get_etats_from_assiduites(self.assiduites),
-            show_pres=show_pres,
-            show_reta=show_reta,
+            show_pres=self.parent.show_pres,
+            show_reta=self.parent.show_reta,
         )
 
         est_just = self._get_color_justificatifs_cascade(
@@ -2609,13 +2611,11 @@ class Jour:
 
         return f"color {etat} {est_just}"
 
-    def get_demi_class(
-        self, matin: bool, show_pres: bool = False, show_reta: bool = False
-    ) -> str:
+    def _get_color_demi(self, matin: bool) -> str:
+        """renvoie la classe css correspondant
+        à la case du calendrier
+        (version journee divisée en demi-journees)
         """
-        Renvoie la class css de la demi journée
-        """
-
         heure_midi = scass.str_to_time(ScoDocSiteConfig.get("assi_lunch_time", "13:00"))
 
         if matin:
@@ -2647,8 +2647,8 @@ class Jour:
 
             etat = self._get_color_assiduites_cascade(
                 self._get_etats_from_assiduites(assiduites_matin),
-                show_pres=show_pres,
-                show_reta=show_reta,
+                show_pres=self.parent.show_pres,
+                show_reta=self.parent.show_reta,
             )
 
             est_just = self._get_color_justificatifs_cascade(
@@ -2687,8 +2687,8 @@ class Jour:
 
         etat = self._get_color_assiduites_cascade(
             self._get_etats_from_assiduites(assiduites_aprem),
-            show_pres=show_pres,
-            show_reta=show_reta,
+            show_pres=self.parent.show_pres,
+            show_reta=self.parent.show_reta,
         )
 
         est_just = self._get_color_justificatifs_cascade(
@@ -2697,77 +2697,6 @@ class Jour:
 
         return f"color {etat} {est_just}"
 
-    def has_assiduites(self) -> bool:
-        """
-        Renverra True si le jour a des assiduités
-        """
-        return self.assiduites.count() > 0
-
-    def generate_minitimeline(self) -> str:
-        """
-        Génère la minitimeline du jour
-        """
-        # Récupérer le référenciel de la timeline
-        heure_matin: datetime.timedelta = _time_to_timedelta(
-            scass.str_to_time(ScoDocSiteConfig.get("assi_morning_time", "08:00"))
-        )
-        heure_soir: datetime.timedelta = _time_to_timedelta(
-            scass.str_to_time(ScoDocSiteConfig.get("assi_afternoon_time", "17:00"))
-        )
-        # longueur_timeline =  heure_soir - heure_matin
-        longueur_timeline: datetime.timedelta = heure_soir - heure_matin
-
-        # chaque block d'assiduité est défini par:
-        # longueur = ( (fin-deb) / longueur_timeline ) * 100
-        # emplacement = ( (deb - heure_matin) / longueur_timeline ) * 100
-        # longueur + emplacement = 100%  sinon on réduit longueur
-
-        assiduite_blocks: list[dict[str, float | str]] = []
-
-        for assi in self.assiduites:
-            deb: datetime.timedelta = _time_to_timedelta(
-                assi.date_debut.time()
-                if assi.date_debut.date() == self.date
-                else heure_matin
-            )
-            fin: datetime.timedelta = _time_to_timedelta(
-                assi.date_fin.time()
-                if assi.date_fin.date() == self.date
-                else heure_soir
-            )
-
-            emplacement: float = max(((deb - heure_matin) / longueur_timeline) * 100, 0)
-            longueur: float = ((fin - deb) / longueur_timeline) * 100
-            if longueur + emplacement > 100:
-                longueur = 100 - emplacement
-            etat: str = scu.EtatAssiduite(assi.etat).name.lower()
-            est_just: str = "est_just" if assi.est_just else ""
-
-            assiduite_blocks.append(
-                {
-                    "longueur": longueur,
-                    "emplacement": emplacement,
-                    "etat": etat,
-                    "est_just": est_just,
-                    "bubble": _generate_assiduite_bubble(assi),
-                    "id": assi.assiduite_id,
-                }
-            )
-
-        return render_template(
-            "assiduites/widgets/minitimeline_simple.j2",
-            assi_blocks=assiduite_blocks,
-        )
-
-    def is_non_work(self):
-        """
-        Renvoie True si le jour est un jour non travaillé
-        (en fonction de la préférence du département)
-        """
-        return self.date.weekday() in scu.NonWorkDays.get_all_non_work_days(
-            dept_id=g.scodoc_dept_id
-        )
-
     def _get_etats_from_assiduites(self, assiduites: Query) -> list[scu.EtatAssiduite]:
         return list(set(scu.EtatAssiduite(assi.etat) for assi in assiduites))
 
@@ -2806,47 +2735,110 @@ class Jour:
 
         return ""
 
+    def _generate_minitimeline(self) -> str:
+        """
+        Génère la minitimeline du jour
+        """
+        # Récupérer le référenciel de la timeline
+        heure_matin: datetime.timedelta = _time_to_timedelta(
+            scass.str_to_time(ScoDocSiteConfig.get("assi_morning_time", "08:00"))
+        )
+        heure_soir: datetime.timedelta = _time_to_timedelta(
+            scass.str_to_time(ScoDocSiteConfig.get("assi_afternoon_time", "17:00"))
+        )
+        # longueur_timeline =  heure_soir - heure_matin
+        longueur_timeline: datetime.timedelta = heure_soir - heure_matin
 
-def _get_dates_between(deb: datetime.date, fin: datetime.date) -> list[datetime.date]:
-    resultat = []
-    date_actuelle = deb
-    while date_actuelle <= fin:
-        resultat.append(date_actuelle)
-        date_actuelle += datetime.timedelta(days=1)
-    return resultat
+        # chaque block d'assiduité est défini par:
+        # longueur = ( (fin-deb) / longueur_timeline ) * 100
+        # emplacement = ( (deb - heure_matin) / longueur_timeline ) * 100
+        # longueur + emplacement = 100%  sinon on réduit longueur
+
+        assiduite_blocks: list[dict[str, float | str]] = []
+
+        for assi in self.assiduites:
+            deb: datetime.timedelta = _time_to_timedelta(
+                assi.date_debut.time()
+                if assi.date_debut.date() == self.date
+                else heure_matin
+            )
+            fin: datetime.timedelta = _time_to_timedelta(
+                assi.date_fin.time()
+                if assi.date_fin.date() == self.date
+                else heure_soir
+            )
+
+            emplacement: float = max(((deb - heure_matin) / longueur_timeline) * 100, 0)
+            longueur: float = ((fin - deb) / longueur_timeline) * 100
+            if longueur + emplacement > 100:
+                longueur = 100 - emplacement
+
+            etat: str = scu.EtatAssiduite(assi.etat).name.lower()
+            est_just: str = "est_just" if assi.est_just else ""
+
+            assiduite_blocks.append(
+                {
+                    "longueur": longueur,
+                    "emplacement": emplacement,
+                    "etat": etat,
+                    "est_just": est_just,
+                    "bubble": _generate_assiduite_bubble(assi),
+                    "id": assi.assiduite_id,
+                }
+            )
+
+        return render_template(
+            "assiduites/widgets/minitimeline_simple.j2",
+            assi_blocks=assiduite_blocks,
+        )
 
 
-def _organize_by_month(days, assiduites, justificatifs) -> dict[str, list[Jour]]:
+class CalendrierAssi(sco_gen_cal.Calendrier):
     """
-    Organiser les dates par mois.
+    Représente un calendrier d'assiduité d'un étudiant
     """
-    organized = {}
-    for date in days:
-        # Récupérer le mois en français
-        month = scu.MONTH_NAMES_ABBREV[date.month - 1]
-        # Ajouter le jour à la liste correspondante au mois
-        if month not in organized:
-            organized[month] = []
 
-        date_assiduites: Query = scass.filter_by_date(
-            assiduites,
+    def __init__(self, annee: int, etudiant: Identite, **options):
+        # On prend du 01/09 au 31/08
+        date_debut: datetime.datetime = datetime.datetime(annee, 9, 1, 0, 0)
+        date_fin: datetime.datetime = datetime.datetime(annee + 1, 8, 31, 23, 59)
+        super().__init__(date_debut, date_fin)
+
+        # On récupère les assiduités et les justificatifs
+        self.etud_assiduites: Query = scass.filter_by_date(
+            etudiant.assiduites,
+            Assiduite,
+            date_deb=date_debut,
+            date_fin=date_fin,
+        )
+        self.etud_justificatifs: Query = scass.filter_by_date(
+            etudiant.justificatifs,
+            Justificatif,
+            date_deb=date_debut,
+            date_fin=date_fin,
+        )
+
+        # Ajout des options (exemple : mode_demi, show_pres, show_reta, ...)
+        for key, value in options.items():
+            setattr(self, key, value)
+
+    def instanciate_jour(self, date: datetime.date) -> JourAssi:
+        """
+        Instancie un jour d'assiduité
+        """
+        assiduites: Query = scass.filter_by_date(
+            self.etud_assiduites,
             Assiduite,
             date_deb=datetime.datetime.combine(date, datetime.time(0, 0)),
             date_fin=datetime.datetime.combine(date, datetime.time(23, 59, 59)),
         )
-
-        date_justificatifs: Query = scass.filter_by_date(
-            justificatifs,
+        justificatifs: Query = scass.filter_by_date(
+            self.etud_justificatifs,
             Justificatif,
             date_deb=datetime.datetime.combine(date, datetime.time(0, 0)),
             date_fin=datetime.datetime.combine(date, datetime.time(23, 59, 59)),
         )
-        # On génère un `Jour` composé d'une date, et des assiduités/justificatifs du jour
-        jour: Jour = Jour(date, date_assiduites, date_justificatifs)
-
-        organized[month].append(jour)
-
-    return organized
+        return JourAssi(date, assiduites, justificatifs, parent=self)
 
 
 def _time_to_timedelta(t: datetime.time) -> datetime.timedelta: