diff --git a/FEATURES/129798 b/FEATURES/129798
new file mode 100644
index 0000000000000000000000000000000000000000..e0bb1b9f44edd5e6d8d6ffad41e5d8af9badb893
--- /dev/null
+++ b/FEATURES/129798
@@ -0,0 +1,10 @@
+        '129798' =>
+            ['Label' => $this->_('Horaires multiples pour les articles/événements'),
+             'Desc' => $this->_('Les articles représentant un événement peuvent maintenant avoir plusieurs horaires. Cela permet d\'éviter de dupliquer un même article pour plusieurs déroulement du même événement. Variable d\'activation : ENABLE_ARTICLES_TIMINGS'),
+             'Image' => '',
+             'Video' => 'https://youtu.be/UdXTNImRmtY',
+             'Category' => $this->('Rédaction'),
+             'Right' => function($feature_description, $user) {return true;},
+             'Wiki' => 'https://wiki.bokeh-library-portal.org/index.php?title=Articles_-_Horaires_multiples',
+             'Test' => '',
+             'Date' => '2021-07-23'],
\ No newline at end of file
diff --git a/VERSIONS_WIP/129798 b/VERSIONS_WIP/129798
new file mode 100644
index 0000000000000000000000000000000000000000..ad559c25ca20e3a83323cb1d2d404cd149f19ae8
--- /dev/null
+++ b/VERSIONS_WIP/129798
@@ -0,0 +1 @@
+ - ticket #129798 : Les articles peuvent avoir plusieurs horaires d'événement (option ENABLE_ARTICLES_TIMINGS). Ajout du filtre Etiquette/Tag pour la boîte agenda.
\ No newline at end of file
diff --git a/application/modules/admin/controllers/CmsController.php b/application/modules/admin/controllers/CmsController.php
index c49761c2634ec0b9f348a3d2312f581298caac88..21274123cc7bdaf5742a97f3c10aabcb9f5e27ab 100644
--- a/application/modules/admin/controllers/CmsController.php
+++ b/application/modules/admin/controllers/CmsController.php
@@ -23,10 +23,10 @@ class Admin_CmsController extends ZendAfi_Controller_Action {
   private $_bib;
 
   public function getPlugins() {
-    return ['ZendAfi_Controller_Plugin_ResourceDefinition_Article',
-            'ZendAfi_Controller_Plugin_Manager_Article',
-            'ZendAfi_Controller_Plugin_MultiSelection_Article',
-            'ZendAfi_Controller_Plugin_Versionning_Article'];
+    return [ZendAfi_Controller_Plugin_ResourceDefinition_Article::class,
+            ZendAfi_Controller_Plugin_Manager_Article::class,
+            ZendAfi_Controller_Plugin_MultiSelection_Article::class,
+            ZendAfi_Controller_Plugin_Versionning_Article::class];
   }
 
 
@@ -170,7 +170,6 @@ class Admin_CmsController extends ZendAfi_Controller_Action {
 
 
   public function appendTreeViewContext() {
-
     if ($this->_getParam('id') &&
         ($article = Class_Article::find($this->_getParam('id')))) {
       $cat_selected =$article->getIdCat();
@@ -211,4 +210,24 @@ class Admin_CmsController extends ZendAfi_Controller_Action {
     $this->getResponse()->setHeader('Content-Type', 'application/json; charset=utf-8');
     $this->getResponse()->setBody((new Class_ArticleCategorie())->getCategoriesJson());
   }
+
+
+  public function eventTimingsAction() {
+    $this->_forward('index', 'event-timings');
+  }
+
+
+  public function eventTimingDeleteAction() {
+    $this->_forward('delete', 'event-timings');
+  }
+
+
+  public function eventTimingEditAction() {
+    $this->_forward('edit', 'event-timings');
+  }
+
+
+  public function eventTimingAddAction() {
+    $this->_forward('add', 'event-timings');
+  }
 }
diff --git a/application/modules/admin/controllers/EventTimingsController.php b/application/modules/admin/controllers/EventTimingsController.php
new file mode 100644
index 0000000000000000000000000000000000000000..ffbf28c7be0abb99752c63ef16f42a0c61311edc
--- /dev/null
+++ b/application/modules/admin/controllers/EventTimingsController.php
@@ -0,0 +1,28 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Admin_EventTimingsController extends ZendAfi_Controller_Action {
+  public function getPlugins() {
+    return [ZendAfi_Controller_Plugin_ResourceDefinition_EventTimings::class,
+            ZendAfi_Controller_Plugin_Manager_EventTimings::class];
+  }
+}
diff --git a/application/modules/admin/views/scripts/event-timings/add.phtml b/application/modules/admin/views/scripts/event-timings/add.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..b10a9aac54eaf11ba6f76ac7048aa0953745be14
--- /dev/null
+++ b/application/modules/admin/views/scripts/event-timings/add.phtml
@@ -0,0 +1 @@
+<?php  echo $this->renderForm($this->form); ?>
diff --git a/application/modules/admin/views/scripts/event-timings/edit.phtml b/application/modules/admin/views/scripts/event-timings/edit.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..c52ca489f905555642d9f8210fc3130ba4abfdcd
--- /dev/null
+++ b/application/modules/admin/views/scripts/event-timings/edit.phtml
@@ -0,0 +1 @@
+<?php echo $this->renderForm($this->form); ?>
diff --git a/application/modules/admin/views/scripts/event-timings/index.phtml b/application/modules/admin/views/scripts/event-timings/index.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..b3c49daebbdab6810f6b83f284a63810d50f2360
--- /dev/null
+++ b/application/modules/admin/views/scripts/event-timings/index.phtml
@@ -0,0 +1,29 @@
+<?php
+
+echo $this->Button_Back(
+  (new Class_Button())
+    ->setText($this->_('Retour à l\'article'))
+    ->setUrl($this->url(['module' => 'admin',
+                         'controller' => 'cms',
+                         'action' => 'edit',
+                         'id' => $this->article->getId()
+                        ],
+                        null,
+                        true))
+    );
+
+echo $this->Button_New(
+  (new Class_Button())
+    ->setText($this->_('Ajouter un horaire'))
+    ->setUrl($this->url(['module' => 'admin',
+                         'controller' => 'cms',
+                         'action' => 'event-timing-add',
+                         'article_id' => $this->article->getId()
+                        ],
+                        null,
+                        true))
+    ->setAttribs(['data-popup' => 'true'])
+    );
+
+echo $this->renderTable(new Class_TableDescription_EventTimings('event_timings'),
+                        $this->article->getEventTimings());
diff --git a/cosmogramme/sql/patch/patch_415.php b/cosmogramme/sql/patch/patch_415.php
new file mode 100644
index 0000000000000000000000000000000000000000..8145b66f2df1c13847050551c6311ce4c788da7b
--- /dev/null
+++ b/cosmogramme/sql/patch/patch_415.php
@@ -0,0 +1,13 @@
+<?php
+$adapter = Zend_Db_Table_Abstract::getDefaultAdapter();
+
+try {
+  $adapter->query('CREATE TABLE if not exists `cms_article_timings` ( '
+                  . 'id int(11) unsigned not null auto_increment,'
+                  . 'article_id int(11) unsigned not null,'
+                  . 'start datetime,'
+                  . 'end datetime,'
+                  . 'primary key (id),'
+                  . 'key `article_id` (`article_id`)'
+                  . ') engine=MyISAM default charset=utf8');
+} catch(Exception $e) { }
diff --git a/library/Class/AdminVar.php b/library/Class/AdminVar.php
index 6124d93ae53a48145a8cdadc873ae7d35306c047..8f4a8f74a37ef115bfb843d8cd1a437317be3e59 100644
--- a/library/Class/AdminVar.php
+++ b/library/Class/AdminVar.php
@@ -321,6 +321,7 @@ class Class_AdminVarLoader extends Storm_Model_Loader {
                                                                     ['value' => 'no-reply@afi-sa.fr']),
             'CUSTOM_GENRE_ICON' => Class_AdminVar_Meta::newOnOff($this->_('Activation de l\'interface de personnalisation des icones des genres')),
             'DISABLE_BLOCKS_SORTING' => Class_AdminVar_Meta::newOnOff($this->_('Désactivation de la possibilité de déplacer les boîtes par glisser/déposer en front')),
+            'ENABLE_ARTICLES_TIMINGS' => Class_AdminVar_Meta::newOnOff($this->_('Activation de la gestion d\'horaires multiples pour les articles d\'événement')),
     ];
   }
 
diff --git a/library/Class/Article.php b/library/Class/Article.php
index 4ecaf884b6ae87b1749b3847b9c6bce691e74d84..92b0d5ea628db2799e4bd77f5be1e7d397f1ff26 100644
--- a/library/Class/Article.php
+++ b/library/Class/Article.php
@@ -19,613 +19,6 @@
  * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
  */
 
-class ArticleLoader extends Storm_Model_Loader {
-  const ORDER_SELECTION = 'Selection';
-
-  /** @var Zend_Db_Table_Select */
-  protected $_select;
-  protected $_sort_order;
-  protected $_nb_analyse;
-  protected $_nb_aff;
-  protected $_id_articles;
-  protected $_id_categories;
-  protected $_limit;
-  /** @var int */
-  protected $_status;
-  /** @var bool */
-  protected $_events_only;
-  /** @var bool */
-  protected $_published;
-  protected $_display_mode;
-  protected $_all_articles;
-
-  /**
-   * @return ArticleLoader
-   */
-  protected function _selectArticles() {
-    if(!$table = $this->getTable())
-      return $this;
-
-    $this->_select = $table
-      ->select('cms_article.*')
-      ->setIntegrityCheck(false)
-      ->from('cms_article');
-
-    return $this;
-  }
-
-
-  protected function _publishedNow() {
-    if (!$this->_published)
-      return $this;
-
-    if(!$this->_select)
-      return $this;
-
-    $this->_select
-      ->where('(DEBUT IS NULL) OR (DEBUT <= CURDATE())')
-      ->where('(FIN IS NULL) OR (FIN >= CURDATE())');
-
-    return $this;
-  }
-
-
-  /**
-   * @param array $id_categories
-   * @return array
-   */
-  protected function _mergeCategorieIdsWithSousCategories($id_categories) {
-    $all_cat_ids = array();
-    foreach($id_categories as $id_cat) {
-      if (!$root_cat = Class_ArticleCategorie::getLoader()->find($id_cat))
-        continue;
-
-      $all_cat_ids []= $id_cat;
-
-      $sub_cats = $root_cat->getRecursiveSousCategories();
-      foreach ($sub_cats as $cat)
-        $all_cat_ids []= $cat->getId();
-    }
-
-    return array_unique($all_cat_ids);
-  }
-
-
-  /**
-   * @param array $id_articles
-   * @param array $id_categories
-   * @return ArticleLoader
-   */
-  protected function _whereSelectionIn($id_articles, $id_categories) {
-    $conditions = array();
-
-    if ($id_articles)
-      $conditions[] = sprintf('%s in (%s)',
-                              $this->getIdField(),
-                              implode(',', $id_articles));
-
-    if ($id_categories)
-      $conditions[] = sprintf('`cms_article`.ID_CAT in (%s)',
-                              implode(',', $id_categories));
-
-    if ($conditions && $this->_select)
-      $this->_select->where(implode(' OR ', $conditions));
-
-    return $this;
-  }
-
-
-  /**
-   * @param string $event_date
-   * @return ArticleLoader
-   */
-  protected function _whereEventDateIn($event_date) {
-    if (!$this->_select)
-      return $this;
-
-    if ($this->_events_only) {
-      $this->_select->where('EVENTS_DEBUT IS NOT NULL');
-      $this->_select->where('EVENTS_FIN IS NOT NULL');
-    }
-
-    if (!$event_date  || (7 > strlen($event_date)))
-      return $this;
-
-    $truncate = (10 == strlen($event_date)) ? '10' : '7';
-
-    $this->_select->where('left(EVENTS_DEBUT,'.$truncate.') <= ?', $event_date);
-    $this->_select->where('left(EVENTS_FIN,'.$truncate.') >= ?', $event_date);
-
-    return $this;
-  }
-
-
-  /**
-   * @param string $event_date
-   * @return ArticleLoader
-   */
-  protected function _whereEventStartAfter($event_date) {
-    if (!$this->_select)
-      return $this;
-
-    if (!$event_date || (7 > strlen($event_date)))
-      return $this;
-
-    $this->_select->where('EVENTS_DEBUT IS NOT NULL');
-    $this->_select->where('EVENTS_FIN IS NOT NULL');
-
-    $field = (10 == strlen($event_date)) ? 'left(EVENTS_DEBUT,10)' : 'left(EVENTS_DEBUT,7)';
-    $this->_select->where("$field > ?", $event_date);
-
-    return $this;
-  }
-
-
-  protected function _whereEventEndAfter($event_date) {
-    if (!$this->_select)
-      return $this;
-
-    if (!$event_date || (10 > strlen($event_date)))
-      return $this;
-
-    $this->_select->where('EVENTS_FIN IS NOT NULL');
-    $this->_select->where("left(EVENTS_FIN,10) >= ?", $event_date);
-
-    return $this;
-
-  }
-
-
-  /**
-   * @return ArticleLoader
-   */
-  protected function _orderAndLimit() {
-    if(!$this->_select)
-      return $this;
-
-    if ($this->_orderByCommentsCount())
-      return $this;
-
-    if ( $this->_orderByTitle() )
-      return $this;
-
-    if (!$this->_has_selection) {
-      $this->_select->order('DATE_CREATION DESC');
-      $this->_select->limit($this->_limit);
-      return $this;
-    }
-
-    if ($this->_id_categories)
-      $this->_select->order(sprintf("FIELD(`cms_article`.ID_CAT, %s)", implode(',', $this->_id_categories)));
-
-    if ($this->_id_articles)
-      $this->_select->order(sprintf("FIELD(ID_ARTICLE, %s)", implode(',', $this->_id_articles)));
-
-    return $this;
-  }
-
-
-  protected function _orderByTitle() {
-    if ( false === strpos($this->_sort_order, 'Title'))
-      return false;
-
-    $order = $this->_sort_order == Intonation_Library_Widget_Carousel_Article_Definition::SORT_TITLE_ASC
-      ? 'asc'
-      : 'desc';
-
-    $this->_select
-      ->order('titre ' . $order);
-
-    return true;
-  }
-
-
-  protected function _orderByCommentsCount() {
-    if ( false === strpos($this->_sort_order, 'CommentCount'))
-      return false;
-
-    $order = $this->_sort_order == 'CommentCount'
-      ? 'desc'
-      : 'asc';
-
-    $this->_select
-        ->join('cms_rank',
-               'cms_rank.ID_CMS = cms_article.ID_ARTICLE',
-               [])
-        ->order('(cms_rank.abon_nombre_avis + cms_rank.bib_nombre_avis) ' . $order);
-    return true;
-  }
-
-
-  /**
-   * @return ArticleLoader
-   */
-  protected function _filterByLangue() {
-    if(!$this->_select)
-      return $this;
-
-    if (!$this->_has_selection) {
-      if ($this->_langue) {
-        $this->_select->where('LANGUE=?', $this->_langue);
-      } else {
-        $this->_select->where('PARENT_ID=?', 0);
-      }
-    }
-
-    return $this;
-  }
-
-
-  /**
-   * @return Zend_Db_Table_Select
-   */
-  protected function _getSelect() {
-    return $this->_select;
-  }
-
-  /**
-   * @param array $articles
-   * @return array
-   */
-  protected function _sortArticles($articles) {
-    if ($this->_sort_order == 'Random') {
-      shuffle($articles);
-    } else {
-      $sort_function = 'sortBy'.$this->_sort_order;
-      if (method_exists('Class_Article', $sort_function))
-        usort($articles, 'Class_Article::'.$sort_function);
-    }
-
-    return $articles;
-  }
-
-
-  public function sortArticles($articles, $sort_order) {
-    if ($this->_sort_order == 'Random') {
-      shuffle($articles);
-    } else {
-      $sort_function = 'sortBy'.$sort_order;
-      if (method_exists('Class_Article', $sort_function))
-        usort($articles, 'Class_Article::'.$sort_function);
-    }
-
-    return $articles;
-  }
-
-
-  /**
-   * @param array $preferences
-   * @return array
-   */
-  protected function _byIdBib($id_bib) {
-    if ((0 === $id_bib) or (null === $id_bib))
-      return $this;
-
-    $this->_select
-      ->join('cms_categorie',
-             'cms_categorie.ID_CAT = cms_article.ID_CAT',
-             array())
-      ->where('cms_categorie.ID_SITE=?', $id_bib);
-    return $this;
-  }
-
-
-  protected function _byIdLieu($id_lieu) {
-    if (null == $id_lieu)
-      return $this;
-
-    $this->_select->where('ID_LIEU=?', $id_lieu);
-    return $this;
-  }
-
-
-  protected function _byPlaceTown($town) {
-    if (!$town || '' == $town)
-      return $this;
-    $this->_select
-      ->join('lieux',
-             'lieux.ID = cms_article.ID_LIEU',
-             array())
-      ->where('trim(lieux.VILLE) like ?', $town);
-
-    return $this;
-  }
-
-
-  protected function _byCustomFields($custom_fields) {
-    foreach ($custom_fields as $id => $value) {
-      $this->_select
-        ->join(
-               [ "cfv$id" => 'custom_field_values' ],
-               "cms_article.ID_ARTICLE = cfv$id.model_id AND cfv$id.custom_field_id = $id",
-               []
-        );
-    }
-
-    return $this;
-  }
-
-  /**
-   * @return ArticleLoader
-   */
-  protected function _filterByStatus() {
-    if (null === $this->_select)
-      return $this;
-
-    $filters = ($filters = $this->_getFilterByWorkflow())
-      ? $filters
-      : $this->_status;
-
-    if ($filters)
-      $this->_select->where('STATUS in (?)', $filters);
-
-    return $this;
-  }
-
-
-  protected function _getFilterByWorkflow() {
-    if (! $this->_filter_by_workflow)
-      return null;
-
-    if (!Class_AdminVar::isWorkflowEnabled())
-      return null;
-
-    $status_filter = [Class_Article::STATUS_VALIDATED];
-
-    if (Class_Users::isCurrentUserCanAccesBackend())
-      $status_filter [] = Class_Article::STATUS_DRAFT;
-
-    if ($this->_status)
-      $status_filter [] = $this->_status;
-
-    return array_unique($status_filter);
-  }
-
-
-  protected function _filterByLocal() {
-    if (! $this->_filter_by_local)
-      return null;
-
-    if (!Class_AdminVar::isTranslationEnabled())
-      return null;
-
-    if ($langue = Zend_Registry::get('translate')->getLocale())
-      $this->_select
-        ->where('(cms_article.langue = "' . $langue . '" or exists (select \'x\' from cms_article as translation where translation.PARENT_ID = cms_article.ID_ARTICLE and trim(translation.langue) = "' . $langue . '") or cms_article.langue = "" or cms_article.langue is null)');
-
-    return function ($articles) use ($langue) {
-      return array_map(function ($article) use ($langue)
-      {
-        return $article->getTraductionLangue($langue);
-      },
-                       $articles);
-    };
-  }
-
-
-  public function getArticlesByPreferencesDefaults() {
-    return ['id_categorie' => '', // catégories d'article, ex: 12-2-8-1-89
-            'id_items' => '', // liste d'articles, ex: 39-28-7
-            'display_order' => '', // tri, cf. méthodes Class_Article::sortByXXX, Random, Selection, CommentCount
-            'order' => '',
-            'nb_analyse' => 0, // afficher nb_aff articles (aléatoires) parmis nb_analyse articles ramenés sur un critère
-            'nb_aff' => null, // nb d'article à retourner
-            'size' => null, // nb d'article à retourner
-            'langue' => null, // que les traductions de cette langue
-            'event_date' => null, // que les articles dont les dates de début et/ou de fin inclue cette date
-            'event_start_after' => null, // que les articles dont l'évènement commence après cette date
-            'event_end_after' => null, // que les articles dont l'évènement termine à ou après cette date
-            'id_bib' => null, // filtre par cette bibliothèque
-            'status' => null, // filtre par cet état de workflow cf. Class_Article::STATUS_XXX
-            'events_only' => false, // filtre que les évènements,
-            'published' => true, // seulement les articles dont les date de debut / fin incluent le jour en cours,
-            'id_lieu' => null,  // id du lieu Class_Lieu
-            'place_town' => null,
-            'display_mode' => 'Title',
-            'custom_fields' => [],
-            'filter_by_workflow' => false,
-            'filter_by_local' => false];
-  }
-
-
-  /**
-   * @param array $preferences
-   * @return array
-   */
-  public function getArticlesByPreferences($preferences) {
-    $preferences = array_merge($this->getArticlesByPreferencesDefaults(),
-                               $preferences);
-
-    $this->_sort_order = $preferences['order']
-      ? $preferences['order']
-      : $preferences['display_order'];
-
-    $this->_nb_aff = (int) $preferences['size']
-      ? (int) $preferences['size']
-      : (int) $preferences['nb_aff'];
-
-    $this->_nb_analyse = (int)$preferences['nb_analyse'];
-    $this->_id_articles = array_filter(explode('-', $preferences['id_items']));
-    $this->_id_categories = $this->_mergeCategorieIdsWithSousCategories(array_filter(explode('-', $preferences['id_categorie'])));
-    $this->_has_selection = (count($this->_id_articles)>0 or count($this->_id_categories)>0);
-    $this->_limit = $this->_sort_order == 'Random' ? $this->_nb_analyse : $this->_nb_aff;
-    $this->_langue = $preferences['langue'];
-    $this->_event_date = $preferences['event_date'];
-    $this->_event_end_after = $preferences['event_end_after'];
-    $this->_event_start_after = $preferences['event_start_after'];
-    $this->_id_bib = $preferences['id_bib'];
-    $this->_id_lieu = (int)$preferences['id_lieu'];
-    $this->_status = $preferences['status'];
-    $this->_events_only = (bool)$preferences['events_only'];
-    $this->_published = (bool)$preferences['published'];
-    $this->_display_mode = $preferences['display_mode'];
-    $this->_custom_fields = $preferences['custom_fields'];
-
-    $this->_filter_by_workflow = $preferences['filter_by_workflow'];
-    $this->_filter_by_local = $preferences['filter_by_local'];
-
-    if ($this->_sort_order == static::ORDER_SELECTION && !$this->_has_selection)
-      return [];
-
-    $select = $this
-      ->_selectArticles()
-      ->_publishedNow()
-      ->_byIdBib($this->_id_bib)
-      ->_byIdLieu($this->_id_lieu)
-      ->_byPlaceTown($preferences['place_town'])
-      ->_whereSelectionIn($this->_id_articles, $this->_id_categories)
-      ->_whereEventDateIn($this->_event_date)
-      ->_whereEventStartAfter($this->_event_start_after)
-      ->_whereEventEndAfter($this->_event_end_after)
-      ->_filterByLangue()
-      ->_filterByStatus()
-      ->_orderAndLimit()
-      ->_getSelect();
-
-    $filter_by_local_callback = $this->_filterByLocal();
-
-    $articles = Class_Article::getLoader()->findAll($select);
-
-    if ($this->_custom_fields)
-      $articles = $this->_filterByCustomFields($articles,
-                                               $this->_custom_fields);
-
-    if ((new ZendAfi_Validate_DateFormat)->isValid($this->_event_date))
-      $articles = $this->_filterByDay($this->_event_date, $articles);
-
-    $articles = $this->_sortArticles($articles);
-
-    $this->_all_articles = $articles = $filter_by_local_callback
-      ? $filter_by_local_callback($articles)
-      : $articles;
-
-    if (
-        ($this->_sort_order == static::ORDER_SELECTION)
-        or !$this->_nb_aff
-    )
-      return $articles;
-
-    return array_slice($articles, 0, $this->_nb_aff);
-  }
-
-
-  public function getAllArticles() {
-    return $this->_all_articles;
-  }
-
-
-  protected function _filterByCustomFields($articles, $custom_fields) {
-    if (!$custom_fields)
-      return $articles;
-
-    $filter = function ($article) use ($custom_fields) {
-      foreach($custom_fields as $id => $value) {
-        if ($value != $article->findCustomFieldValueMatching($id,$value))
-          return false;
-      }
-      return true;
-    };
-    $articles = array_filter($articles, $filter);
-
-    return $articles;
-  }
-
-
-  /**
-   * @param array $articles
-   * @return array
-   */
-  public static function groupByBibId(array $articles) {
-    $grouped = [];
-
-    foreach ($articles as $article) {
-      $bib_id = ($bib = $article->getBib()) ? $bib->getId() : 0;
-
-      if (array_key_exists($bib_id, $grouped)) {
-        $grouped[$bib_id][] = $article;
-      } else {
-        $grouped[$bib_id] = array($article);
-      }
-    }
-
-    return $grouped;
-  }
-
-
-  /**
-   * @param array $articles
-   * @return array
-   */
-  public function groupByLibelleCategorie(array $articles) {
-    $grouped = array();
-
-    foreach ($articles as $article) {
-      $libelle_cat = $article->getCategorie()->getLibelle();
-
-      if (!array_key_exists($libelle_cat, $grouped))
-        $grouped[$libelle_cat] = array();
-
-      $grouped[$libelle_cat][] = $article;
-    }
-
-    return $grouped;
-  }
-
-
-  /**
-   * Retourne les traductions des articles valides et pour la langue courant
-   * @param array
-   * @return array
-   */
-  public function filterByLocaleAndWorkflow($articles) {
-    return Class_Article::filterByLocaleAndWorkflow($articles);
-  }
-
-  public function articlesWithFormulaire() {
-    return Class_Article::findAll('select id_article,titre from cms_article where id_article in (select distinct id_article from formulaires)');
-  }
-
-
-  public function indexAll() {
-    $page = 1;
-    while($articles_to_index = Class_Article::findAllBy(['indexation' => '1',
-                                                         'limitPage' => [$page, 500]])) {
-      foreach($articles_to_index as $article)
-        $article->index();
-
-      Class_Article::clearCache();
-      $page++;
-    }
-  }
-
-
-  protected function _filterByDay($day, $articles) {
-    if(!$day)
-      return $articles;
-
-    $day_num = date("w", strtotime($day));
-    return  array_filter($articles,
-                         function($event) use ($day_num)
-                         {
-                           $pick_day_as_array = $event->getPickDayAsArray();
-                           if(empty($pick_day_as_array) || in_array($day_num, $pick_day_as_array))
-                             return $event;
-                         });
-  }
-
-
-  public function findDistinctStatus($articles) {
-    $ids = array_map(function($article) {return $article->getId();},
-                     $articles);
-
-    return Class_Article::findAll('select distinct status  from cms_article  where id_article in ('.
-                                  implode(',',$ids).
-                                  ')');
-  }
-}
-
-
-
 class Class_Article extends Storm_Model_Abstract {
   use
     Trait_Translator,
@@ -648,40 +41,45 @@ class Class_Article extends Storm_Model_Abstract {
 
   public $old_status = null;
 
-  protected $_loader_class = 'ArticleLoader';
+  protected $_loader_class = Class_Article_Loader::class;
   protected $_table_name = 'cms_article';
   protected $_table_primary = 'ID_ARTICLE';
-  protected $_has_many = ['traductions' => ['model' => 'Class_Article',
+  protected $_has_many = ['traductions' => ['model' => Class_Article::class,
                                             'role' => 'article_original',
                                             'dependents' => 'delete'],
 
-                          'avis_users' => ['model' => 'Class_Avis',
+                          'avis_users' => ['model' => Class_Avis::class,
                                            'role' => 'article',
                                            'dependents' => 'delete',
                                            'order' => 'date_avis desc'],
 
-                          'formulaires' => ['model' => 'Class_Formulaire',
+                          'formulaires' => ['model' => Class_Formulaire::class,
                                             'role' => 'article',
                                             'dependents' => 'delete',
                                             'order' => 'date_creation desc'],
 
-                          'formulaires_to_validate' => ['model' => 'Class_Formulaire',
+                          'formulaires_to_validate' => ['model' => Class_Formulaire::class,
                                                         'role' => 'article',
                                                         'order' => 'date_creation desc',
-                                                        'scope' => ['validated' => false]]];
+                                                        'scope' => ['validated' => false]],
 
-  protected $_belongs_to = ['categorie' => ['model' => 'Class_ArticleCategorie',
+                          'event_timings' => ['model' => Class_Article_EventTiming::class,
+                                              'role' => 'article',
+                                              'order' => 'start',
+                                              'dependents' => 'delete']];
+
+  protected $_belongs_to = ['categorie' => ['model' => Class_ArticleCategorie::class,
                                             'referenced_in' => 'id_cat'],
 
-                            'article_original' => ['model' => 'Class_Article',
+                            'article_original' => ['model' => Class_Article::class,
                                                    'referenced_in' => 'parent_id'],
 
                             'bib' => ['through' => 'categorie'],
 
-                            'lieu' => ['model' => 'Class_Lieu',
+                            'lieu' => ['model' => Class_Lieu::class,
                                        'referenced_in' => 'id_lieu'] ,
 
-                            'auteur' => ['model' => 'Class_Users',
+                            'auteur' => ['model' => Class_Users::class,
                                          'referenced_in' => 'id_user']];
 
 
@@ -1593,13 +991,6 @@ class Class_Article extends Storm_Model_Abstract {
   }
 
 
-  public static function hasEventForMonth($month) {
-    return 0 < count(Class_Article::getLoader()->getArticlesByPreferences(['event_date'=>self::getTimeSource()->getMonth($month),
-                                                                           'events_only' => true,
-                                                                           'status'=>3]));
-  }
-
-
   public function updateAttributes(Array $attributes) {
     unset($attributes['id_items']);
 
@@ -1631,6 +1022,7 @@ class Class_Article extends Storm_Model_Abstract {
   }
 
 
+
   public function acceptClefAlphaVisitor($visitor) {
     $visitor->visitTitre($this->getTitre())
             ->visitComplementTitre($this->getId())
@@ -1665,4 +1057,10 @@ class Class_Article extends Storm_Model_Abstract {
       ? true
       : $this->isVisible();
   }
+
+
+  public function hasTag($tag) {
+    return in_array(strtolower($tag),
+                    array_filter(explode(';', strtolower($this->getTags()))));
+  }
 }
diff --git a/library/Class/Article/EventTiming.php b/library/Class/Article/EventTiming.php
new file mode 100644
index 0000000000000000000000000000000000000000..2397876f0c3d240a7174e5a5fe29536240d2e878
--- /dev/null
+++ b/library/Class/Article/EventTiming.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_Article_EventTiming extends Storm_Model_Abstract {
+  use Trait_TimeSource, Trait_Translator;
+
+  protected
+    $_table_name = 'cms_article_timings',
+    $_belongs_to = [
+                    'article' => ['model' => Class_Article::class,
+                                  'referenced_in' => 'article_id']],
+    $_default_attribute_values = ['start' => '',
+                                  'end' => '',
+                                  'article_id' => null];
+
+
+  public function isPast() {
+    return strtotime($this->getEnd()) < $this->getCurrentTime();
+  }
+
+
+  public function getLibelle() {
+    return ($article = $this->getArticle())
+      ? $article->getTitre()
+      : '';
+  }
+
+
+  public function updateAttributes(Array $attributes) {
+    $date_iso = new Class_Date_Iso;
+    if (array_key_exists('start', $attributes))
+      $attributes['start'] = $date_iso->ensureDateTime($attributes['start']);
+
+    if (array_key_exists('end', $attributes))
+      $attributes['end'] = $date_iso->ensureDateTime($attributes['end']);
+
+    return parent::updateAttributes($attributes);
+  }
+
+
+  public function validate() {
+    $this->checkAttribute('start',
+                          $this->getStart(),
+                          $this->_('La date de début n\'est pas une date valide'))
+         ->checkAttribute('end',
+                          $this->getEnd(),
+                          $this->_('La date de fin n\'est pas une date valide'))
+         ->checkAttribute('end',
+                          Class_Date::isEndDateAfterStartDateNotEmpty($this->getStart(), $this->getEnd()),
+                          $this->_('La date de début doit être antérieure à la date de fin'));
+  }
+}
diff --git a/library/Class/Article/Loader.php b/library/Class/Article/Loader.php
new file mode 100644
index 0000000000000000000000000000000000000000..a57bfc23575e4b6ea670161c25eb2b5012a77cf6
--- /dev/null
+++ b/library/Class/Article/Loader.php
@@ -0,0 +1,259 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+class Class_Article_Loader extends Storm_Model_Loader {
+  const
+    ORDER_SELECTION = 'Selection',
+    ORDER_TITLE_ASC = 'TitleAsc',
+    ORDER_TITLE_DESC = 'TitleDesc',
+    ORDER_COMMENTS_ASC = 'CommentCountAsc',
+    ORDER_COMMENTS = 'CommentCount';
+
+
+  protected $_all_articles;
+
+
+  public function newFromRow($row) {
+    $row = array_change_key_case($row, CASE_LOWER);
+    if (isset($row['start']) && isset($row['end'])) {
+      $row['events_debut'] = $row['start'];
+      $row['events_fin'] = $row['end'];
+      unset($row['start']);
+      unset($row['end']);
+    }
+
+    return parent::newFromRow($row);
+  }
+
+
+  /**
+   * @param array $articles
+   * @return array
+   */
+  protected function _sortArticles($articles, $sort_order) {
+    if ($sort_order == 'Random') {
+      shuffle($articles);
+      return $articles;
+    }
+
+    $sort_function = 'sortBy'.$sort_order;
+    if (method_exists(Class_Article::class, $sort_function))
+      usort($articles, 'Class_Article::'.$sort_function);
+
+    return $articles;
+  }
+
+
+  public function getArticlesByPreferencesDefaults() {
+    return ['id_categorie' => '', // catégories d'article, ex: 12-2-8-1-89
+            'id_items' => '', // liste d'articles, ex: 39-28-7
+            'display_order' => '', // tri, cf. méthodes Class_Article::sortByXXX, Random, Selection, CommentCount
+            'order' => '',
+            'nb_analyse' => 0, // afficher nb_aff articles (aléatoires) parmis nb_analyse articles ramenés sur un critère
+            'nb_aff' => null, // nb d'article à retourner
+            'size' => null, // nb d'article à retourner
+            'langue' => null, // que les traductions de cette langue
+            'event_date' => null, // que les articles dont les dates de début et/ou de fin inclue cette date
+            'event_start_after' => null, // que les articles dont l'évènement commence après cette date
+            'event_end_after' => null, // que les articles dont l'évènement termine à ou après cette date
+            'id_bib' => null, // filtre par cette bibliothèque
+            'status' => null, // filtre par cet état de workflow cf. Class_Article::STATUS_XXX
+            'events_only' => false, // filtre que les évènements,
+            'published' => true, // seulement les articles dont les date de debut / fin incluent le jour en cours,
+            'id_lieu' => null,  // id du lieu Class_Lieu
+            'place_town' => null,
+            'display_mode' => 'Title',
+            'custom_fields' => [],
+            'tag' => '' ];
+  }
+
+
+  /**
+   * @param array $preferences
+   * @return array
+   */
+  public function getArticlesByPreferences($preferences) {
+    $preferences = array_merge($this->getArticlesByPreferencesDefaults(),
+                               $preferences);
+
+
+    $select = Class_AdminVar::isModuleEnabled('ENABLE_ARTICLES_TIMINGS')
+      ? new Class_Article_SelectWithTimings($this)
+      : new Class_Article_Select($this);
+
+    $articles = $select->findAll($preferences);
+    $articles = $this->_filterByCustomFields($articles,
+                                             $preferences['custom_fields']);
+    $articles = $this->_filterByTag($articles,
+                                    $preferences['tag']);
+
+    $event_date = $preferences['event_date'];
+    if ((new ZendAfi_Validate_DateFormat)->isValid($event_date))
+      $articles = $this->_filterByDay($event_date, $articles);
+
+    $this->_all_articles = $articles = $this->_sortArticles($articles,
+                                                            $select->getSortOrder());
+
+    if (
+        ($select->getSortOrder() == static::ORDER_SELECTION)
+        ||
+        (! $select->getNumberOfArticles())
+    )
+      return $articles;
+
+    return array_slice($articles, 0, $select->getNumberOfArticles());
+  }
+
+
+  public function getAllArticles() {
+    return $this->_all_articles;
+  }
+
+
+  protected function  _filterByTag($articles, $tag) {
+    if (!$tag)
+      return $articles;
+
+    return array_filter($articles,
+                        function($article) use ($tag) {
+                          return $article->hasTag($tag);
+                        });
+  }
+
+
+  protected function _filterByCustomFields($articles, $custom_fields) {
+    if (!$custom_fields)
+      return $articles;
+
+    $filter = function ($article) use ($custom_fields) {
+      foreach($custom_fields as $id => $value) {
+        if ($value != $article->findCustomFieldValueMatching($id,$value))
+          return false;
+      }
+      return true;
+    };
+    $articles = array_filter($articles, $filter);
+
+    return $articles;
+  }
+
+
+  /**
+   * @param array $articles
+   * @return array
+   */
+  public static function groupByBibId(array $articles) {
+    $grouped = [];
+
+    foreach ($articles as $article) {
+      $bib_id = ($bib = $article->getBib()) ? $bib->getId() : 0;
+
+      if (array_key_exists($bib_id, $grouped)) {
+        $grouped[$bib_id][] = $article;
+      } else {
+        $grouped[$bib_id] = array($article);
+      }
+    }
+
+    return $grouped;
+  }
+
+
+  /**
+   * @param array $articles
+   * @return array
+   */
+  public function groupByLibelleCategorie(array $articles) {
+    $grouped = array();
+
+    foreach ($articles as $article) {
+      $libelle_cat = $article->getCategorie()->getLibelle();
+
+      if (!array_key_exists($libelle_cat, $grouped))
+        $grouped[$libelle_cat] = array();
+
+      $grouped[$libelle_cat][] = $article;
+    }
+
+    return $grouped;
+  }
+
+
+  /**
+   * Retourne les traductions des articles valides et pour la langue courant
+   * @param array
+   * @return array
+   */
+  public function filterByLocaleAndWorkflow($articles) {
+    return Class_Article::filterByLocaleAndWorkflow($articles);
+  }
+
+  public function articlesWithFormulaire() {
+    return Class_Article::findAll('select id_article,titre from cms_article where id_article in (select distinct id_article from formulaires)');
+  }
+
+
+  public function indexAll() {
+    $page = 1;
+    while($articles_to_index = Class_Article::findAllBy(['indexation' => '1',
+                                                         'limitPage' => [$page, 500]])) {
+      foreach($articles_to_index as $article)
+        $article->index();
+
+      Class_Article::clearCache();
+      $page++;
+    }
+  }
+
+
+  protected function _filterByDay($day, $articles) {
+    if(!$day)
+      return $articles;
+
+    $day_num = date("w", strtotime($day));
+    return  array_filter($articles,
+                         function($event) use ($day_num)
+                         {
+                           $pick_day_as_array = $event->getPickDayAsArray();
+                           if(empty($pick_day_as_array) || in_array($day_num, $pick_day_as_array))
+                             return $event;
+                         });
+  }
+
+
+  public function findDistinctStatus($articles) {
+    $ids = array_map(function($article) {return $article->getId();},
+                     $articles);
+
+    return Class_Article::findAll('select distinct status  from cms_article  where id_article in ('.
+                                  implode(',',$ids).
+                                  ')');
+  }
+
+
+  public function hasEventForMonth($month) {
+    return 0 < count(Class_Article::getLoader()
+                     ->getArticlesByPreferences(['event_date'=> Class_Article::getTimeSource()->getMonth($month),
+                                                 'events_only' => true,
+                                                 'status'=>3]));
+  }
+
+}
diff --git a/library/Class/Article/Select.php b/library/Class/Article/Select.php
new file mode 100644
index 0000000000000000000000000000000000000000..670a1aac497fa6127a5a829624b415ca8da3514b
--- /dev/null
+++ b/library/Class/Article/Select.php
@@ -0,0 +1,433 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_Article_Select {
+  protected
+    $_article_loader,
+    $_select,   /** @var Zend_Db_Table_Select */
+    $_sort_order,
+    $_nb_analyse,
+    $_nb_aff,
+    $_id_articles,
+    $_id_categories,
+    $_limit,
+    $_status,
+    $_events_only,
+    $_published,
+    $_display_mode,
+    $_place_town;
+
+
+  public function __construct($article_loader) {
+    $this->_article_loader = $article_loader;
+  }
+
+
+  public function findAll($preferences) {
+    $this->_initSearchCriterias($preferences);
+
+    if ($this->_sort_order == Class_Article_Loader::ORDER_SELECTION && !$this->_has_selection)
+      return [];
+
+    $select = $this->_buildSelect();
+    return Class_Article::findAll($select);
+  }
+
+
+  public function getNumberOfArticles() {
+    return $this->_nb_aff;
+  }
+
+
+  public function getSortOrder() {
+    return $this->_sort_order;
+  }
+
+
+  protected function _initSearchCriterias($preferences) {
+    $this->_sort_order = $preferences['order']
+      ? $preferences['order']
+      : $preferences['display_order'];
+
+    $this->_nb_aff = (int) $preferences['size']
+      ? (int) $preferences['size']
+      : (int) $preferences['nb_aff'];
+
+    $this->_nb_analyse = (int)$preferences['nb_analyse'];
+    $this->_id_articles = array_filter(explode('-', $preferences['id_items']));
+    $this->_id_categories = $this->_mergeCategorieIdsWithSousCategories(array_filter(explode('-', $preferences['id_categorie'])));
+    $this->_has_selection = (count($this->_id_articles)>0 or count($this->_id_categories)>0);
+    $this->_limit = $this->_sort_order == 'Random' ? $this->_nb_analyse : $this->_nb_aff;
+    $this->_langue = $preferences['langue'];
+    $this->_event_date = $preferences['event_date'];
+    $this->_event_end_after = $preferences['event_end_after'];
+    $this->_event_start_after = $preferences['event_start_after'];
+    $this->_id_bib = $preferences['id_bib'];
+    $this->_id_lieu = (int)$preferences['id_lieu'];
+    $this->_status = $preferences['status'];
+    $this->_events_only = (bool)$preferences['events_only'];
+    $this->_published = (bool)$preferences['published'];
+    $this->_display_mode = $preferences['display_mode'];
+    $this->_custom_fields = $preferences['custom_fields'];
+    $this->_place_town = $preferences['place_town'];
+
+    return $this;
+  }
+
+
+  protected function _buildSelect() {
+    return $this
+      ->_selectArticles()
+      ->_publishedNow()
+      ->_byIdBib($this->_id_bib)
+      ->_byIdLieu($this->_id_lieu)
+      ->_byPlaceTown($this->_place_town)
+      ->_whereSelectionIn($this->_id_articles, $this->_id_categories)
+      ->_whereEventDateIn($this->_event_date)
+      ->_whereEventStartAfter($this->_event_start_after)
+      ->_whereEventEndAfter($this->_event_end_after)
+      ->_filterByLangue()
+      ->_filterByStatus()
+      ->_orderAndLimit()
+      ->_getSelect();
+  }
+
+
+  protected function _selectArticles() {
+    if(!$table = $this->_article_loader->getTable())
+      return $this;
+
+    $this->_select = $table
+      ->select('cms_article.*')
+      ->setIntegrityCheck(false)
+      ->from('cms_article');
+
+    return $this;
+  }
+
+
+  protected function _publishedNow() {
+    if (!$this->_published)
+      return $this;
+
+    if(!$this->_select)
+      return $this;
+
+    $this->_select
+      ->where('(DEBUT IS NULL) OR (DEBUT <= CURDATE())')
+      ->where('(FIN IS NULL) OR (FIN >= CURDATE())');
+
+    return $this;
+  }
+
+
+  /**
+   * @param array $id_categories
+   * @return array
+   */
+  protected function _mergeCategorieIdsWithSousCategories($id_categories) {
+    $all_cat_ids = array();
+    foreach($id_categories as $id_cat) {
+      if (!$root_cat = Class_ArticleCategorie::getLoader()->find($id_cat))
+        continue;
+
+      $all_cat_ids []= $id_cat;
+
+      $sub_cats = $root_cat->getRecursiveSousCategories();
+      foreach ($sub_cats as $cat)
+        $all_cat_ids []= $cat->getId();
+    }
+
+    return array_unique($all_cat_ids);
+  }
+
+
+  /**
+   * @param array $id_articles
+   * @param array $id_categories
+   * @return ArticleLoader
+   */
+  protected function _whereSelectionIn($id_articles, $id_categories) {
+    $conditions = array();
+
+    if ($id_articles)
+      $conditions[] = sprintf('%s in (%s)',
+                              $this->_article_loader->getIdField(),
+                              implode(',', $id_articles));
+
+    if ($id_categories)
+      $conditions[] = sprintf('`cms_article`.ID_CAT in (%s)',
+                              implode(',', $id_categories));
+
+    if ($conditions && $this->_select)
+      $this->_select->where(implode(' OR ', $conditions));
+
+    return $this;
+  }
+
+
+  protected function _selectWhereEventStart($template, $value = null) {
+    $this->_select->where(sprintf($template, 'EVENTS_DEBUT'), $value);
+    return $this;
+  }
+
+
+  protected function _selectWhereEventEnd($template, $value = null) {
+    $this->_select->where(sprintf($template, 'EVENTS_FIN'), $value);
+    return $this;
+  }
+
+
+  protected function _selectAllEvents() {
+    return $this
+        ->_selectWhereEventStart('%s IS NOT NULL')
+        ->_selectWhereEventEnd('%s IS NOT NULL');
+  }
+
+
+  protected function _selectHasEventStartEnd() {
+    return $this;
+  }
+
+
+  protected function _selectHasEventEnd() {
+    $this->_select->where('EVENTS_FIN IS NOT NULL');
+    return $this;
+  }
+
+
+  /**
+   * @param string $event_date
+   * @return ArticleLoader
+   */
+  protected function _whereEventDateIn($event_date) {
+    if (!$this->_select)
+      return $this;
+
+    if ($this->_events_only && empty($event_date))
+      return $this->_selectAllEvents();
+
+    if (!$event_date  || !preg_match('/\d{4}-\d{2}(-\d{2})?/',$event_date))
+      return $this;
+
+    $truncate = (10 == strlen($event_date)) ? '10' : '7';
+
+    $this
+      ->_selectHasEventStartEnd()
+      ->_selectWhereEventStart('left(%s,'.$truncate.') <= ?', $event_date)
+      ->_selectWhereEventEnd('left(%s,'.$truncate.') >= ?', $event_date);
+
+    return $this;
+  }
+
+
+  /**
+   * @param string $event_date
+   * @return ArticleLoader
+   */
+  protected function _whereEventStartAfter($event_date) {
+    if (!$this->_select)
+      return $this;
+
+    if (!$event_date || !preg_match('/\d{4}-\d{2}(-\d{2})?/',$event_date))
+      return $this;
+
+    $this
+      ->_selectWhereEventStart('%s IS NOT NULL')
+      ->_selectWhereEventEnd('%s IS NOT NULL');
+
+    $field = (10 == strlen($event_date)) ? 'left(%s,10)' : 'left(%s,7)';
+    $this->_selectWhereEventStart("$field > ?", $event_date);
+
+    return $this;
+  }
+
+
+  protected function _selectWhereEventEndAfterDay($day) {
+    $this
+      ->_selectHasEventEnd()
+      ->_selectWhereEventEnd('left(%s,10) >= ?', $day);
+    return $this;
+  }
+
+
+  protected function _whereEventEndAfter($event_date) {
+    if (!$this->_select || !$event_date || !preg_match('/\d{4}-\d{2}(-\d{2})?/', $event_date))
+      return $this;
+
+    return $this->_selectWhereEventEndAfterDay($event_date);
+  }
+
+
+  protected function _orderAndLimit() {
+    if(!$this->_select)
+      return $this;
+
+    if ($this->_orderByCommentsCount())
+      return $this;
+
+    if ( $this->_orderByTitle() )
+      return $this;
+
+    if (!$this->_has_selection) {
+      $this->_select->order('DATE_CREATION DESC');
+      $this->_select->limit($this->_limit);
+      return $this;
+    }
+
+    if ($this->_id_categories)
+      $this->_select->order(sprintf("FIELD(`cms_article`.ID_CAT, %s)", implode(',', $this->_id_categories)));
+
+    if ($this->_id_articles)
+      $this->_select->order(sprintf("FIELD(ID_ARTICLE, %s)", implode(',', $this->_id_articles)));
+
+    return $this;
+  }
+
+
+  protected function _orderByTitle() {
+    if ( false === strpos($this->_sort_order, 'Title'))
+      return false;
+
+    $order = $this->_sort_order == Class_Article_Loader::ORDER_TITLE_ASC
+      ? 'asc'
+      : 'desc';
+
+    $this->_select
+      ->order('titre ' . $order);
+
+    return true;
+  }
+
+
+  protected function _orderByCommentsCount() {
+    if ( false === strpos($this->_sort_order, 'CommentCount'))
+      return false;
+
+    $order = $this->_sort_order == Class_Article_Loader::ORDER_COMMENTS
+      ? 'desc'
+      : 'asc';
+
+    $this->_select
+        ->join('cms_rank',
+               'cms_rank.ID_CMS = cms_article.ID_ARTICLE',
+               [])
+        ->order('(cms_rank.abon_nombre_avis + cms_rank.bib_nombre_avis) ' . $order);
+    return true;
+  }
+
+
+  protected function _filterByLangue() {
+    if(!$this->_select)
+      return $this;
+
+    if ($this->_has_selection)
+      return $this;
+
+    $where = $this->_langue
+      ? 'LANGUE=?'
+      : 'PARENT_ID=?' ;
+
+    $value = $this->_langue
+      ? $this->_langue
+      : 0 ;
+
+    $this->_select->where($where, $value);
+    return $this;
+  }
+
+
+  protected function _getSelect() {
+    return $this->_select;
+  }
+
+
+
+  /**
+   * @param array $preferences
+   * @return array
+   */
+  protected function _byIdBib($id_bib) {
+    if ((0 === $id_bib) or (null === $id_bib))
+      return $this;
+
+    $this->_select
+      ->join('cms_categorie',
+             'cms_categorie.ID_CAT = cms_article.ID_CAT',
+             array())
+      ->where('cms_categorie.ID_SITE=?', $id_bib);
+    return $this;
+  }
+
+
+  protected function _byIdLieu($id_lieu) {
+    if (null == $id_lieu)
+      return $this;
+
+    $this->_select->where('ID_LIEU=?', $id_lieu);
+    return $this;
+  }
+
+
+  protected function _byPlaceTown($town) {
+    if (!$town || '' == $town)
+      return $this;
+    $this->_select
+      ->join('lieux',
+             'lieux.ID = cms_article.ID_LIEU',
+             array())
+      ->where('trim(lieux.VILLE) like ?', $town);
+
+    return $this;
+  }
+
+
+  protected function _byCustomFields($custom_fields) {
+    foreach ($custom_fields as $id => $value) {
+      $this->_select
+        ->join(
+               [ "cfv$id" => 'custom_field_values' ],
+               "cms_article.ID_ARTICLE = cfv$id.model_id AND cfv$id.custom_field_id = $id",
+               []
+        );
+    }
+
+    return $this;
+  }
+
+
+  protected function _filterByStatus() {
+    if (null === $this->_status || !is_array($this->_status)) {
+      return $this;
+    }
+
+    if (null === $this->_select) {
+      return $this;
+    }
+
+    $this->_select->where('STATUS in (?)', $this->_status);
+
+    return $this;
+  }
+
+
+}
diff --git a/library/Class/Article/SelectWithTimings.php b/library/Class/Article/SelectWithTimings.php
new file mode 100644
index 0000000000000000000000000000000000000000..2ca761cab01547dcbdf436b11112a396d8d9ebd0
--- /dev/null
+++ b/library/Class/Article/SelectWithTimings.php
@@ -0,0 +1,82 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_Article_SelectWithTimings extends Class_Article_Select {
+  use Trait_TimeSource;
+
+  protected function _selectArticles() {
+    parent::_selectArticles();
+    $this->_select
+      ->joinLeft('cms_article_timings',
+                 'cms_article.id_article=cms_article_timings.article_id',
+                 ['start', 'end'])
+      ->group('id_article');
+    return $this;
+  }
+
+
+  protected function _selectWhereEventStart($template, $value = null) {
+    $this->_select->where(sprintf('%s OR %s',
+                                  sprintf($template, 'EVENTS_DEBUT'),
+                                  sprintf($template, 'START')),
+                          $value);
+    return $this;
+  }
+
+
+  protected function _selectWhereEventEnd($template, $value = null) {
+    /*
+     * if article as multiple timings and asking for current month, for calendar we need
+     * to select the first timing above current date so users can see the next event.
+     */
+    $is_query_current_month = empty($value) || ($value == date('Y-m', $this->getCurrentTime()));
+
+    $clauses =  $is_query_current_month
+      ? '%s OR (%s AND END >= CURDATE())'
+      : '%s OR %s';
+
+    $this->_select->where(sprintf($clauses,
+                                  sprintf($template, 'EVENTS_FIN'),
+                                  sprintf($template, 'END')),
+                          $value);
+    return $this;
+  }
+
+
+  protected function _selectHasEventStartEnd() {
+    return $this
+      ->_selectHasEventStart()
+      ->_selectHasEventEnd();
+  }
+
+
+  protected function _selectHasEventStart() {
+    $this->_select->where('EVENTS_DEBUT IS NOT NULL OR START IS NOT NULL');
+    return $this;
+  }
+
+
+  protected function _selectHasEventEnd() {
+    $this->_select->where('EVENTS_FIN IS NOT NULL OR END IS NOT NULL');
+    return $this;
+  }
+}
diff --git a/library/Class/Calendar.php b/library/Class/Calendar.php
index a04851a259374d56ac21aaa7f067d2f3c23809ac..b58a5c4dae13b3935fef22188c1761440ec0dfd1 100644
--- a/library/Class/Calendar.php
+++ b/library/Class/Calendar.php
@@ -22,17 +22,6 @@
 class Class_Calendar {
   use Trait_TimeSource, Trait_Translator;
 
-  var $PREFIX         = "calendar_";
-  var $URL_PARAMETER  = "date";
-  var $PRESERVE_URL   = true;
-  var $LANGUAGE_CODE  = "fr";
-  var $WEEK_DAYS;
-  var $MONTH_HEADER = array('fr' => "%m %y");
-  var $events = array();
-  var $day;
-  var $month;
-  var $year;
-
   protected
     $id_module,
     $preferences,
@@ -41,6 +30,7 @@ class Class_Calendar {
     $id_categorie = '',
     $id_bib,
     $date,
+    $day,
     $_article_event_helper;
 
 
@@ -159,10 +149,14 @@ class Class_Calendar {
         && (!$id_lieu)
         && !$town)
       return [];
+
     $prefs = [];
     if ($town)
       $prefs['place_town'] = $town;
 
+    if ($tag = $this->_getParam('tag', null))
+      $prefs['tag'] = $tag;
+
     $prefs = array_merge($prefs,
                          ['display_order' => (isset($this->preferences['display_order'])
                                               ? $this->preferences['display_order']
@@ -239,8 +233,13 @@ class Class_Calendar {
                                              'event_date' => '',
                                              'event_end_after' => '',
                                              'limit' => $this->_getSize()]);
-      $articles = array_merge($articles,
-                              Class_Article::filterByLocaleAndWorkflow($next_articles));
+      $next_articles = array_udiff($next_articles,
+                                   $articles,
+                                   function ($article, $next_article)
+                                   {
+                                     return $article->getId() - $next_article->getId();
+                                   });
+      $articles = array_merge($articles, $next_articles);
     }
 
     return $articles;
@@ -396,4 +395,4 @@ class Class_Calendar {
     $this->preferences['display_full_page'] = $boolean;
     return $this;
   }
-}
\ No newline at end of file
+}
diff --git a/library/Class/Date.php b/library/Class/Date.php
index ca0f9635734484adc2237357b0bcb0da5a17c69f..d86cc7d562b8514ef930a791b8a23b7ddf9d4b43 100644
--- a/library/Class/Date.php
+++ b/library/Class/Date.php
@@ -41,24 +41,6 @@ class Class_Date {
   }
 
 
-  /*
-   * @param string $dateFormat Format used to generate the date
-   * @return string Today's date
-   * @return false if failed
-   */
-  function DateDuJour($dateFormat)
-  {
-    try{
-      $zendDate =  new Zend_Date();
-      $dateDuJour = $zendDate->toString($dateFormat);
-    }catch (Exception $e){
-      logErrorMessage('Class: Class_Date; Function: DateDuJour' . NL . $e->getMessage());
-      $dateDuJour = false;
-    }
-
-    return $dateDuJour;
-  }
-
   /*
    * Uses Date Format = 'yyyy-MM-dd HH:mm:ss'
    * @return string Today's date and time
@@ -88,60 +70,6 @@ class Class_Date {
     return $localizedDate;
   }
 
-  /*
-   * @param string $varDate date to be localized
-   * @param string $dateFormat Format used to generate the date
-   * @return string localized date
-   * @return false if failed
-   */
-  function RendDateSql($varDate)
-  {
-    try{
-      $zendDate =  new Zend_Date($varDate, $this->_localeDateFormat, $this->_locale);
-      $sqlDate = $zendDate->toString('-MM-dd');
-    }catch (Exception $e){
-      logErrorMessage('Class: Class_Date; Function: RendDateSql' . NL . $e->getMessage());
-      $sqlDate = false;
-    }
-
-    return $sqlDate;
-  }
-
-  /*
-   * @param string $varDate1 date
-   * @param string $varDate1 date
-   * @return int The difference in days between the 2 dates
-   */
-  function SoustraitDates($varDate1, $varDate2)
-  {
-
-    $date1 = strtotime($varDate1);
-    $date2 = strtotime($varDate2);
-
-    $differenceDays = (integer)(($date1 - $date2) / (24*60*60));
-
-    return $differenceDays;
-  }
-
-  /*
-   * @param string $varDate date
-   * @param int $jours number of days
-   * @param string $dateFormat Format used to generate the date
-   * @return string new date = $varDate + $jours
-   * @return false if failed
-   */
-  function AjouterJours($varDate, $jours, $dateFormat)
-  {
-    try{
-      $zendDate =  new Zend_Date($varDate, $dateFormat, $this->_locale);
-      $zendDate->add((int)$jours, Zend_Date::DAY);
-      return $zendDate->toString($dateFormat);
-    }catch (Exception $e){
-      logErrorMessage('Class: Class_Date; Function: AjouterJours' . NL . $e->getMessage());
-      return false;
-    }
-
-  }
 
   /*
    * @return int current time
@@ -156,25 +84,6 @@ class Class_Date {
     return $time;
   }
 
-  /*
-   * @param int $t time
-   * @return string in format '11 h 40 min. 33 sec.'
-   */
-  function getSecMinHTime($t)
-  {
-    $temps = "";
-    $secondes = $t % 60;
-    $minutes = (int)($t/60);
-    $heures = (int)($minutes/60);
-    $minutes = $minutes % 60;
-    if( $heures > 0 )$temps = $heures . " h ";
-    if( $minutes > 0 ) $temps .= $minutes . " min. ";
-    $temps .= $secondes . " sec.";
-
-    return $temps;
-  }
-
-
 
   public static function isEndDateAfterStartDate($start_date, $end_date) {
     if (empty($start_date) || empty($end_date))
diff --git a/library/Class/Date/Iso.php b/library/Class/Date/Iso.php
index 896a4479ddea07d699f41ef0be1ab0a9fcd4f637..45eca1e086ffe5d55fa1490a02ad0fa5714d9635 100644
--- a/library/Class/Date/Iso.php
+++ b/library/Class/Date/Iso.php
@@ -92,6 +92,22 @@ class Class_Date_Iso {
 
 
 class Class_Date_IsoWithTime extends Class_Date_Iso {
+
+  const TIME_PART_ISO = 'T(\d{2}:\d{2})?:\d{2}\.\d{3}Z$';
+
+  public function ensure($str) {
+    if ($matches = $this->_match($this->_isoPattern(), $str))
+      return $this->_fromIso($matches);
+
+    return parent::ensure($str);
+  }
+
+
+  protected function _isoPattern() {
+    return static::DATE_PART_US . static::TIME_PART_ISO . '$';
+  }
+
+
   protected function _frPattern() {
     return static::DATE_PART_FR . static::TIME_PART . '$';
   }
@@ -112,6 +128,11 @@ class Class_Date_IsoWithTime extends Class_Date_Iso {
   }
 
 
+  protected function _fromIso($matches) {
+    return parent::_fromUs($matches) . ' ' . $this->_ensureTime($matches);
+  }
+
+
   protected function _ensureTime($matches) {
     return isset($matches[4]) ? $matches[4] : ' 00:00';
   }
diff --git a/library/Class/ExternalAgenda.php b/library/Class/ExternalAgenda.php
index 7a97ee78788e5ee67617a42e5f1133ef5bb0f8d4..6780dc2a719f08a58249bdb832b546ec79eaeff3 100644
--- a/library/Class/ExternalAgenda.php
+++ b/library/Class/ExternalAgenda.php
@@ -122,8 +122,8 @@ class Class_ExternalAgenda extends Storm_Model_Abstract {
 
 
   protected function _newProvider() {
-    $map = [static::OPEN_AGENDA => 'Class_ExternalAgenda_OpenAgenda',
-            static::ICALENDAR => 'Class_ExternalAgenda_ICalendar'];
+    $map = [static::OPEN_AGENDA => Class_ExternalAgenda_OpenAgenda::class,
+            static::ICALENDAR => Class_ExternalAgenda_ICalendar::class];
 
     return array_key_exists($this->getProvider(), $map)
       ? new $map[$this->getProvider()]
diff --git a/library/Class/ExternalAgenda/OpenAgenda.php b/library/Class/ExternalAgenda/OpenAgenda.php
index dcdef4757ee42164629fd4b71b319d59c36c28ec..f4584f891f7282f90449c15334656899a64b4e51 100644
--- a/library/Class/ExternalAgenda/OpenAgenda.php
+++ b/library/Class/ExternalAgenda/OpenAgenda.php
@@ -21,16 +21,17 @@
 
 
 class Class_ExternalAgenda_OpenAgenda extends Class_ExternalAgenda_Provider {
+  protected function _import() {
+    $event_builder =  Class_AdminVar::isModuleEnabled('ENABLE_ARTICLES_TIMINGS')
+      ? (new Timings())
+      : (new Articles());
 
-  public function _import() {
     $page = 1;
     while ($content = json_decode($this->httpGet($this->_pageUrl($page)), true)) {
       if (!isset($content['events']) || !is_array($content['events']) || !$content['events'])
         break;
 
-      foreach($content['events'] as $event)
-        $this->_processEvent($event);
-
+      $this->_buildPage($event_builder, $content['events']);
       $page++;
     }
 
@@ -38,37 +39,90 @@ class Class_ExternalAgenda_OpenAgenda extends Class_ExternalAgenda_Provider {
   }
 
 
+  protected function _buildPage($builder, $events) {
+      foreach($events as $event)
+        $builder->processEvent(new Class_ExternalAgenda_OpenAgenda_Event($event),
+                                     $this);
+  }
+
+
+  public function newEvent() {
+    return $this->_newEvent();
+  }
+
+
+  public function appendEvent($article) {
+    return $this->_events->append($article);
+  }
+
+
   protected function _pageUrl($page) {
     return $this->_url() . '&page=' . $page;
   }
+}
 
 
-  protected function _processEvent($datas){
-    $event = new Class_ExternalAgenda_OpenAgenda_Event($datas);
 
-    foreach($event->get('timings') as $timing) {
-      $article = $this->_buildArticleForTiming($event, $timing);
-      $this->_events->append($article);
-    }
 
+abstract class EventBuilder_Abstract {
+  public function processEvent($event, $open_agenda) {
     return $this;
   }
 
 
-  protected function _buildArticleForTiming($event, $timing) {
-    return $this
-      ->_newEvent()
+  protected function _buildArticle($event, $open_agenda) {
+    return $open_agenda
+      ->newEvent()
       ->setTitre($event->getString('title'))
       ->setContenu($event->getImageTagWithCredits()
                    .$event->getHtml()
                    .$event->getInfosTag()
                    .$event->getInscriptionsTag())
-      ->setIdOrigine($event->get('uid') . '_' . base64_encode($timing['start']))
       ->setDescription($event->getImageTag().'<p>'.$event->getString('description').'</p>')
+      ->setDateMaj($event->getUpdatedAt())
       ->setTags(implode(';', $event->getKeywords()))
       ->setLieu($event->getLocation())
-      ->setEventsDebut(date('Y-m-d H:i', strtotime($timing['start'])))
-      ->setEventsFin(date('Y-m-d H:i', strtotime($timing['end'])));
+      ->setIdOrigine($event->getUid());
+  }
+}
+
+
+
+
+class Articles extends EventBuilder_Abstract {
+  public function processEvent($event, $open_agenda){
+    foreach($event->get('timings') as $timing) {
+      $article = $this->_buildArticleForTiming($event, $timing, $open_agenda);
+      $open_agenda->appendEvent($article);
+    }
+
+    return $this;
+  }
+
+
+  protected function _buildArticleForTiming($event, $timing, $open_agenda) {
+    return $this->_buildArticle($event, $open_agenda)
+                ->setIdOrigine($event->getUid($timing['start']))
+                ->setEventsDebut(date('Y-m-d H:i', strtotime($timing['start'])))
+                ->setEventsFin(date('Y-m-d H:i', strtotime($timing['end'])));
+  }
+}
+
+
+
+
+class Timings extends EventBuilder_Abstract {
+  public function processEvent($event, $open_agenda){
+    $article = $this->_buildArticle($event, $open_agenda)
+                    ->setIdOrigine($event->getUid());
+
+    foreach($event->get('timings') as $timing)
+      $article->addEventTiming((new Class_Article_EventTiming())
+                               ->setStart($timing['start'])
+                               ->setEnd($timing['end']));
+
+    $open_agenda->appendEvent($article);
+    return $this;
   }
 }
 
@@ -95,6 +149,11 @@ class Class_ExternalAgenda_OpenAgenda_Event {
   }
 
 
+  public function getUpdatedAt() {
+    return $this->_event['updatedAt'];
+  }
+
+
   public function getImageTag(){
     return ($src = $this->_event['image'])
       ? sprintf('<img src="%s" alt=""/>',$src)
@@ -102,6 +161,12 @@ class Class_ExternalAgenda_OpenAgenda_Event {
   }
 
 
+  public function getUid($start = null) {
+    return !Class_AdminVar::isModuleEnabled('ENABLE_ARTICLES_TIMINGS')
+      ? $this->get('uid') . '_' . base64_encode($start)
+      : $this->get('uid');
+  }
+
   public function getInfosTag() {
     $infos = '';
 
diff --git a/library/Class/FilterSettings.php b/library/Class/FilterSettings.php
index e28f2828344f7911b8ce1aa1df65b43433c58c65..e26728280869e1a91abb7e4d854d5ae8d693c705 100644
--- a/library/Class/FilterSettings.php
+++ b/library/Class/FilterSettings.php
@@ -20,9 +20,62 @@
  */
 
 
-class Class_FilterSettings extends Class_Entity {
+class Class_FilterSettings {
+  protected
+    $_rendered_models = [],
+    $_filter_names,
+    $_calendar,
+    $_selected_filters,
+    $_default_filters,
+    $_use_default_filters,
+    $_model_label,
+    $_url_params,
+    $_filters_display_mode,
+    $_filters_position,
+    $_id_module,
+    $_on_load_complete,
+    $_module;
+
+
   public function __construct($module) {
-    $this->setModule($module);
+    $this->_module = $module;
+  }
+
+
+  public function getModule() {
+    return $this->_module;
+  }
+
+
+  public function setFilters($filter_names) {
+    $this->_filter_names = $filter_names;
+    return $this;
+  }
+
+
+  public function getFilters() {
+    return $this->_filter_names;
+  }
+
+
+  public function setCalendar($calendar) {
+    $this->_calendar = $calendar;
+    return $this;
+  }
+
+
+  public function getCalendar() {
+    return $this->_calendar;
+  }
+
+
+  public function setRenderedModels($models) {
+    $this->_rendered_models = $models;
+  }
+
+
+  public function renderedModelsMap($callable) {
+    return array_map($callable, $this->_rendered_models);
   }
 
 
@@ -78,4 +131,103 @@ class Class_FilterSettings extends Class_Entity {
       ? $active_filters
       : [];
   }
-}
\ No newline at end of file
+
+
+  public function setSelectedFilters($filter_names) {
+    $this->_selected_filters = $filter_names;
+    return $this;
+  }
+
+
+  public function getSelectedFilters() {
+    return $this->_selected_filters;
+  }
+
+
+  public function setUseDefaultFilters($use_defaults) {
+    $this->_use_default_filters = $use_defaults;
+    return $this;
+  }
+
+
+  public function getUseDefaultFilters() {
+    return $this->_use_default_filters;
+  }
+
+
+  public function setDefaultFilters($filter_names) {
+    $this->_default_filters = $filter_names;
+    return $this;
+  }
+
+
+  public function getDefaultFilters() {
+    return $this->_default_filters;
+  }
+
+
+  public function setModelLabel($label) {
+    $this->_model_label = $label;
+    return $this;
+  }
+
+
+  public function getModelLabel() {
+    return $this->_model_label;
+  }
+
+
+  public function setUrlParams($url_params) {
+    $this->_url_params = $url_params;
+    return $this;
+  }
+
+
+  public function getUrlParams() {
+    return $this->_url_params;
+  }
+
+
+  public function setFiltersDisplayMode($display_mode) {
+    $this->_filters_display_mode = $display_mode;
+    return $this;
+  }
+
+
+  public function getFiltersDisplayMode() {
+    return $this->_filters_display_mode;
+  }
+
+
+  public function setFiltersPosition($position) {
+    $this->_filters_position = $position;
+    return $this;
+  }
+
+
+  public function getFiltersPosition() {
+    return $this->_filters_position;
+  }
+
+
+  public function setIdModule($id_module) {
+    $this->_id_module = $id_module;
+    return $this;
+  }
+
+
+  public function getIdModule() {
+    return $this->_id_module;
+  }
+
+
+  public function setOnLoadComplete($javascript) {
+    $this->_on_load_complete = $javascript;
+    return $this;
+  }
+
+
+  public function getOnLoadComplete() {
+    return $this->_on_load_complete;
+  }
+}
diff --git a/library/Class/Systeme/ModulesAccueil/Calendrier.php b/library/Class/Systeme/ModulesAccueil/Calendrier.php
index a9b9e9436daba78c847bf64564951132ec515674..3f8833bf589d3fe93ec88814ff1576c0f433a964 100644
--- a/library/Class/Systeme/ModulesAccueil/Calendrier.php
+++ b/library/Class/Systeme/ModulesAccueil/Calendrier.php
@@ -63,7 +63,8 @@ class Class_Systeme_ModulesAccueil_Calendrier extends Class_Systeme_ModulesAccue
     $available_filters = ['day' => $this->_('Date'),
                           'date' => $this->_('Mois'),
                           'place' => $this->_('Lieu'),
-                          'place_town' => $this->_('Ville')];
+                          'place_town' => $this->_('Ville'),
+                          'tag' => $this->_('Étiquette')];
 
     $custom_fields = Class_CustomField_Model::getModel('Article')->getFields();
 
@@ -72,4 +73,4 @@ class Class_Systeme_ModulesAccueil_Calendrier extends Class_Systeme_ModulesAccue
 
     return $available_filters;
   }
-}
\ No newline at end of file
+}
diff --git a/library/Class/TableDescription/EventTimings.php b/library/Class/TableDescription/EventTimings.php
new file mode 100644
index 0000000000000000000000000000000000000000..4542fb819e8ab344f5b6d9cd1f6eebce5f54072d
--- /dev/null
+++ b/library/Class/TableDescription/EventTimings.php
@@ -0,0 +1,59 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_TableDescription_EventTimings extends Class_TableDescription {
+
+  public function init() {
+
+    $edit_url = Class_Url::assemble(['module' => 'admin',
+                            'controller' => 'cms',
+                            'action' => 'event-timing-edit'],
+                           null,
+                           true) . '/id/%s';
+
+    $delete_url = Class_Url::assemble(['module' => 'admin',
+                              'controller' => 'cms',
+                              'action' => 'event-timing-delete'],
+                             null,
+                             true) . '/id/%s';
+
+    $this->addColumn($this->_('Début'), 'start')
+         ->addColumn($this->_('Fin'), 'end')
+         ->addRowAction(['url' => $edit_url,
+                         'label' => function($event_timing) {
+                               return $this->_('Modifier l\'horaire %s - %s',
+                                               $event_timing->getStart(),
+                                               $event_timing->getEnd());
+                              },
+                         'icon' => 'edit',
+                         'anchorOptions' => ['data-popup' => 'true']])
+         ->addRowAction(['url' => $delete_url,
+                         'label' => function($event_timing) {
+                               return $this->_('Supprimer l\'horaire %s - %s',
+                                               $event_timing->getStart(),
+                                               $event_timing->getEnd());
+                              },
+                         'icon' => 'delete',
+                         'anchorOptions' => ['onclick' => "return confirm('"
+                                             . $this->_('êtes-vous sûr de supprimer cet horaire?') . ");"]]);
+  }
+}
diff --git a/library/ZendAfi/Acl/AdminControllerRoles.php b/library/ZendAfi/Acl/AdminControllerRoles.php
index 8f26a14f7df17dbb575b1053e45f27d99cd97332..f2250c1fc9c19581c98abfe69cb0628106cd3826 100644
--- a/library/ZendAfi/Acl/AdminControllerRoles.php
+++ b/library/ZendAfi/Acl/AdminControllerRoles.php
@@ -59,6 +59,7 @@ class ZendAfi_Acl_AdminControllerRoles extends Zend_Acl {
     $this->add(new Zend_Acl_Resource('agenda'));
     $this->add(new Zend_Acl_Resource('auth'));
     $this->add(new Zend_Acl_Resource('cms'));
+    $this->add(new Zend_Acl_Resource('event-timings'));
     $this->add(new Zend_Acl_Resource('cms-category'));
     $this->add(new Zend_Acl_Resource('ckeditor'));
     $this->add(new Zend_Acl_Resource('data'));
@@ -151,6 +152,7 @@ class ZendAfi_Acl_AdminControllerRoles extends Zend_Acl {
     $this->allow('invite','auth');
 
     $this->allow('modo_bib','cms');
+    $this->allow('modo_bib','event-timings');
     $this->allow('modo_bib','ckeditor');
     $this->allow('modo_bib','cms-category');
     $this->allow('modo_bib','ajax');
diff --git a/library/ZendAfi/Controller/Plugin/Manager/Article.php b/library/ZendAfi/Controller/Plugin/Manager/Article.php
index f4459b7f25aae63e82c3ed34b795286798dad233..ebce05da8c2cbe73d688ff766b338afc1f22f518 100644
--- a/library/ZendAfi/Controller/Plugin/Manager/Article.php
+++ b/library/ZendAfi/Controller/Plugin/Manager/Article.php
@@ -313,61 +313,64 @@ class ZendAfi_Controller_Plugin_Manager_Article extends ZendAfi_Controller_Plugi
     if (!$model)
       return [];
 
-    if ('Class_Article' == get_class($model))
+    if (Class_Article::class == get_class($model))
       return $this->_articleActions($model);
 
-    if ('Class_ArticleCategorie' == get_class($model))
+    if (Class_ArticleCategorie::class == get_class($model))
       return $this->_articleCategoryActions($model);
 
-    if ('Class_Bib' == get_class($model))
+    if (Class_Bib::class == get_class($model))
       return $this->_bibActions($model);
 
     return [];
   }
 
 
-  protected function _articleActions($model) {
-    $this->identity = Class_Users::getIdentity();
-
-    $permission_closure = function($model) {
-      return $this->identity
-      ->hasAnyPermissionOn($model->getCategorie(),
-                           [Class_Permission::createArticle(),
-                            Class_Permission::createArticleCategory()]);
-    };
-    return [
-            ['url' => '/admin/cms/makeinvisible/id/%s',
-             'icon' => 'show',
-             'label' => $this->_('Rendre cet article invisible'),
-             'condition' => function($model) use ($permission_closure) {
-                return $permission_closure($model) && $model->isVisible();
-             }],
-
-            ['url' => '/admin/cms/makevisible/id/%s',
-             'icon'  => 'hide',
-             'label' => $this->_('Rendre cet article visible'),
-             'condition' => function($model) use ($permission_closure) {
-                return $permission_closure($model) && $model->isNotVisible();
-             }],
-
-            ['url' => $this->_view->url(['module' => 'admin',
-                                         'controller' => 'cms',
-                                         'action' => 'edit',
-                                         'id' => $model->getId()]),
-             'icon'  => 'edit',
-             'label' => $this->_('Modifier'),
-             'condition' => $permission_closure],
-
-            ['url' => '/admin/cms/newsduplicate/id/%s',
-             'icon'  => 'copy',
-             'label' => $this->_('Dupliquer'),
-             'condition' => $permission_closure],
-
-            ['url' => '/admin/cms/delete/id/%s',
-             'icon'  => 'delete',
-             'label' => $this->_('Supprimer'),
-             'condition' => $permission_closure]
-    ];
+  protected function _articleActions($article) {
+    $actions = [];
+
+    if (!Class_Users::getIdentity()
+        ->hasAnyPermissionOn($article->getCategorie(),
+                             [Class_Permission::createArticle(),
+                              Class_Permission::createArticleCategory()]))
+      return $actions;
+
+    if ($article->isVisible())
+      $actions []= ['url' => '/admin/cms/makeinvisible/id/%s',
+                    'icon' => 'show',
+                    'label' => $this->_('Rendre cet article invisible : %s', $article->getTitre())];
+
+    if ($article->isNotVisible())
+      $actions []= ['url' => '/admin/cms/makevisible/id/%s',
+                    'icon'  => 'hide',
+                    'label' => $this->_('Rendre l\'article visible : %s', $article->getTitre())];
+
+    $actions []= ['url' => $this->_view->url(['module' => 'admin',
+                                              'controller' => 'cms',
+                                              'action' => 'edit',
+                                              'id' => $article->getId()]),
+                  'icon'  => 'edit',
+                  'label' => $this->_('Modifier l\'article : %s', $article->getTitre())];
+
+    if (Class_AdminVar::isModuleEnabled('ENABLE_ARTICLES_TIMINGS'))
+      $actions []= ['url' => $this->_view->url(['module' => 'admin',
+                                                'controller' => 'cms',
+                                                'action' => 'event-timings',
+                                                'article_id' => $article->getId()],
+                                               null,
+                                               true),
+                    'icon'  => 'calendar',
+                    'label' => $this->_('Horaires de l\'événement : %s', $article->getTitre())];
+
+    $actions []= ['url' => '/admin/cms/newsduplicate/id/%s',
+                  'icon'  => 'copy',
+                  'label' => $this->_('Dupliquer l\'article : %s', $article->getTitre())];
+
+    $actions []= ['url' => '/admin/cms/delete/id/%s',
+                  'icon'  => 'delete',
+                  'label' => $this->_('Supprimer l\'article : %s', $article->getTitre())];
+
+    return $actions;
   }
 
 
diff --git a/library/ZendAfi/Controller/Plugin/Manager/EventTimings.php b/library/ZendAfi/Controller/Plugin/Manager/EventTimings.php
new file mode 100644
index 0000000000000000000000000000000000000000..2044f0e0472e8c26d253f29f90d88ca0569bcdcf
--- /dev/null
+++ b/library/ZendAfi/Controller/Plugin/Manager/EventTimings.php
@@ -0,0 +1,74 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_Controller_Plugin_Manager_EventTimings extends ZendAfi_Controller_Plugin_Manager_Manager {
+  protected
+    $_event_timing,
+    $_article;
+
+  public function init() {
+    parent::init();
+    $this->_event_timing = Class_Article_EventTiming::find($this->_request->getParam('id'));
+    $this->_view->article = $this->_article = $this->_event_timing
+      ? $this->_event_timing->getArticle()
+      : Class_Article::find($this->_request->getParam('article_id'));
+  }
+
+
+  public function indexAction() {
+    if (!$this->_article)
+      return $this->_redirect('admin/cms');
+
+    $this->_view->titre =  $this->_('Horaires de l\'événement : %s',
+                                   $this->_article->getTitre());
+    $this->_view->article = $this->_article;
+  }
+
+
+  protected function _redirectToIndex() {
+    $url = 'admin/cms';
+    if ($this->_article)
+      $url .= '/event-timings/article_id/' . $this->_article->getId();
+
+    $this->_redirect($url);
+  }
+
+
+  protected function _doBeforeSave($event_timing) {
+    if ($event_timing->isNew())
+      $event_timing->setArticle($this->_article);
+  }
+
+
+  protected function _getEditUrl($event_timing) {
+    return ['module' => 'admin',
+            'controller' => 'cms',
+            'action' => 'event-timing-edit',
+            'id' => $event_timing->getId(),
+            'article_id' => $event_timing->getArticleId()];
+  }
+
+
+  protected function _canEdit($event_timing) {
+    return Class_Users::getIdentity()->canEditArticle($this->_article);
+  }
+}
diff --git a/library/ZendAfi/Controller/Plugin/ResourceDefinition/EventTimings.php b/library/ZendAfi/Controller/Plugin/ResourceDefinition/EventTimings.php
new file mode 100644
index 0000000000000000000000000000000000000000..64776ccae537c75e546ae231a3b5ca7ebc4a2a27
--- /dev/null
+++ b/library/ZendAfi/Controller/Plugin/ResourceDefinition/EventTimings.php
@@ -0,0 +1,37 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_Controller_Plugin_ResourceDefinition_EventTimings extends ZendAfi_Controller_Plugin_ResourceDefinition_Abstract {
+
+  public function getDefinitions() {
+    return ['model' => ['class' => Class_Article_EventTiming::class,
+                        'name' => 'event-timings',
+                        'order' => 'start',
+                        'scope' => 'article_id'],
+            'messages' => ['successful_add' => $this->_('Un nouvel horaire a été créé'),
+                           'successful_save' => $this->_('Les horaires ont été mis à jour'),
+                           'successful_delete' => $this->_('L\'horaire a été supprimé')],
+            'actions' => ['edit' => ['title' => $this->_('%s : modifier un horaire')],
+                          'add' => ['title' => $this->_('Ajouter un horaire')]],
+            'form_class_name' => ZendAfi_Form_Admin_EventTimings::class];
+  }
+}
diff --git a/library/ZendAfi/Form/Admin/EventTimings.php b/library/ZendAfi/Form/Admin/EventTimings.php
new file mode 100644
index 0000000000000000000000000000000000000000..757ace548856d917ac8a13ad1b6e9ed7980f2057
--- /dev/null
+++ b/library/ZendAfi/Form/Admin/EventTimings.php
@@ -0,0 +1,43 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_Form_Admin_EventTimings extends ZendAfi_Form {
+  public function init() {
+    parent::init();
+
+    $this->addElement('datePicker',
+                      'start',
+                      ['dateOnly' => false,
+                       'required' => true,
+                       'allowEmpty' => false,
+                       'validators' => [(new ZendAfi_Validate_DateFormat)->setFormat('d/m/Y H:i')],
+                       'label' => $this->_('Date et heure de début')])
+         ->addElement('datePicker',
+                      'end',
+                      ['dateOnly' => false,
+                       'required' => true,
+                       'allowEmpty' => false,
+                       'validators' => [(new ZendAfi_Validate_DateFormat)->setFormat('d/m/Y H:i')],
+                       'label' => $this->_('Date et heure de fin')])
+         ->addUniqDisplayGroup('timings');
+  }
+}
diff --git a/library/ZendAfi/View/Helper/Admin/HelpLink.php b/library/ZendAfi/View/Helper/Admin/HelpLink.php
index 094a4449c5642b688fd8140cf359ddc3ffafa99d..15710800919cff552aeffc915ba7149a07612f0a 100644
--- a/library/ZendAfi/View/Helper/Admin/HelpLink.php
+++ b/library/ZendAfi/View/Helper/Admin/HelpLink.php
@@ -80,7 +80,10 @@ class ZendAfi_View_Helper_Admin_HelpLinkBokehWiki {
                                   'add-website' => 'Sitothèque_ressource_numérique',
                                   'import_ead' => 'Import-Export_EAD'],
      'catalogue'              => ['index' => 'Domaines'],
-     'cms'                    => ['index' => 'Articles_-_Créer,_rédiger_et_ordonner'],
+     'cms'                    => ['index' => 'Articles_-_Créer,_rédiger_et_ordonner',
+                                  'event-timings' => 'Articles_-_Horaires_multiples',
+                                  'event-timing-edit' => 'Articles_-_Horaires_multiples',
+                                  'event-timing-add' => 'Articles_-_Horaires_multiples'],
      'custom-fields'          => ['index' => 'Gestion_des_champs_personnalisés'],
      'custom-fields-report'   => ['index' => 'Rapports_statistiques'],
      'activity'               => ['index' => 'Gestion_des_activités' ],
diff --git a/library/ZendAfi/View/Helper/Article/RenderAbstract.php b/library/ZendAfi/View/Helper/Article/RenderAbstract.php
index 5d1ee892d2748f7dbe8480a398c58d9f5893c141..b99f325c0f6903837f20b3cecf52920e5c4749b1 100644
--- a/library/ZendAfi/View/Helper/Article/RenderAbstract.php
+++ b/library/ZendAfi/View/Helper/Article/RenderAbstract.php
@@ -34,6 +34,7 @@ abstract class ZendAfi_View_Helper_Article_RenderAbstract
                     ['class' => 'article_content'])
       . $this->_tag('footer',
                     $this->renderLieu($article)
+                    .$this->renderTimingsIfEnabled($article)
                     .$this->renderICalLink($article)
                     .$this->renderPrintLink($article)
                     .$this->renderReseauxSociaux($article)
@@ -52,7 +53,6 @@ abstract class ZendAfi_View_Helper_Article_RenderAbstract
     return $this->view->tagArticleEvent($article);
   }
 
-
   public function renderArticleHTML($html, $article) {
     return $this->getRenderContainerStrategy()->renderArticleHTML($html, $article);
   }
@@ -140,4 +140,14 @@ abstract class ZendAfi_View_Helper_Article_RenderAbstract
   public function renderArticleInfo($article) {
     return $this->view->tagArticleInfo($article);
   }
+
+
+  public function renderTimingsIfEnabled($article) {
+    return Class_AdminVar::isModuleEnabled('ENABLE_ARTICLES_TIMINGS')
+      ? $this->renderTimings($article)
+      : '';
+  }
+
+
+  public function renderTimings($article) { return ''; }
 }
diff --git a/library/ZendAfi/View/Helper/Article/RenderEventTiming.php b/library/ZendAfi/View/Helper/Article/RenderEventTiming.php
new file mode 100644
index 0000000000000000000000000000000000000000..3912fc929e1e8a7b608e8741de6f69412b96c618
--- /dev/null
+++ b/library/ZendAfi/View/Helper/Article/RenderEventTiming.php
@@ -0,0 +1,105 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_View_Helper_Article_RenderEventTiming extends ZendAfi_View_Helper_BaseHelper {
+  public function article_RenderEventTiming($start, $end, $all_day = false, $pick_days = []) {
+    $date_start = strtotime(substr($start, 0, 10));
+
+    $time_start = date('H:i', strtotime($start));
+    $time_end = date('H:i', strtotime($end));
+
+    if (!$date_end = strtotime(substr($end, 0, 10))) {
+      $date_end = $date_start;
+      $time_end = $time_start;
+    }
+
+    return $this->_formatEventString($date_start,
+                                     $date_end,
+                                     $time_start,
+                                     $time_end,
+                                     $all_day,
+                                     $pick_days);
+  }
+
+
+
+  public function _formatEventString($date_start,
+                                     $date_end,
+                                     $time_start,
+                                     $time_end,
+                                     $all_day,
+                                     $picked_days) {
+    if ($date_start == $date_end) {
+      $event_string = strftime('%A %d %B %Y', $date_start);
+
+      if ($all_day)
+        return $event_string;
+
+      return $event_string . (($time_start == $time_end)  ? $this->_(' à %s', $time_start)
+                                                          : $this->_(' de %s à %s', $time_start, $time_end));
+    }
+
+    $year_start = strftime('%Y', $date_start);
+    $year_end = strftime('%Y', $date_end);
+
+    $month_start = strftime('%B', $date_start);
+    $month_end = strftime('%B', $date_end);
+
+
+    if ($month_start == $month_end && $year_start == $year_end)
+      $month_start = '';
+
+    if ($year_start == $year_end)
+      $year_start = '';
+
+    if(empty($picked_days) || 7 == count($picked_days))
+      return $this->_('Du %s au %s',
+                      trim(strftime('%A %d', $date_start) . ' ' . $month_start . ' ' . $year_start),
+                      trim(strftime('%A %d', $date_end) . ' ' . $month_end . ' ' . $year_end));
+
+    $open_days = $this->getTextualDays($picked_days);
+
+    return $this->_('Les ' . $open_days . ' du %s au %s',
+                    trim(strftime('%e', $date_start) . ' ' . $month_start . ' ' . $year_start),
+                    trim(strftime('%e', $date_end) . ' ' . $month_end . ' ' . $year_end));
+  }
+
+
+  protected function getTextualDays($days) {
+    if(1 == count($days))
+      return  $this->_('%ss', $this->numericDayToTextual($days[0]));
+
+    $last = array_pop($days);
+    $last = $this->_('%ss', $this->numericDayToTextual($last));
+
+    $textual_days = '';
+    foreach($days as $day)
+      $textual_days.= $this->_('%ss, ', $this->numericDayToTextual($day));
+
+    return substr($textual_days, 0, -2) . $this->_(' et ') . $last;
+  }
+
+
+  protected function numericDayToTextual($nb) {
+    return strftime('%A', strtotime('Sunday +' . $nb . ' days'));
+  }
+}
diff --git a/library/ZendAfi/View/Helper/Article/RenderEventTimings.php b/library/ZendAfi/View/Helper/Article/RenderEventTimings.php
new file mode 100644
index 0000000000000000000000000000000000000000..2162b48f89b24e728dbdf94a789c37c4a22949f0
--- /dev/null
+++ b/library/ZendAfi/View/Helper/Article/RenderEventTimings.php
@@ -0,0 +1,47 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_View_Helper_Article_RenderEventTimings extends ZendAfi_View_Helper_BaseHelper {
+  public function article_RenderEventTimings($article) {
+    $current_and_future_timings = array_filter($article->getEventTimings(),
+                                               function($timing)
+                                               {
+                                                 return !$timing->isPast();
+                                               });
+
+    $human_timings = array_map(
+                               function($timing)
+                               {
+                                 return $this->view
+                                   ->article_RenderEventTiming($timing->getStart(),
+                                                               $timing->getEnd());
+                               },
+                               $current_and_future_timings);
+
+    return $this->view->tag('div',
+                            $this->view->tag('h2',
+                                             $this->_('Dates et Horaires'))
+                            .
+                            $this->view->tagUlLi($human_timings),
+                            ['class' => 'article_timings']);
+  }
+}
diff --git a/library/ZendAfi/View/Helper/Article/RenderFullContent.php b/library/ZendAfi/View/Helper/Article/RenderFullContent.php
index 8ba940fcc8e36c7a044bd922d75aed66409def8b..48d281c35523048b421feed4fe1ecb25cbbd91c1 100644
--- a/library/ZendAfi/View/Helper/Article/RenderFullContent.php
+++ b/library/ZendAfi/View/Helper/Article/RenderFullContent.php
@@ -55,4 +55,9 @@ class ZendAfi_View_Helper_Article_RenderFullContent extends ZendAfi_View_Helper_
                                                       ->setStrategy('Article_List')),
                             ['class' => 'print']);
   }
+
+
+  public function renderTimings($article) {
+    return $this->view->article_RenderEventTimings($article);
+  }
 }
diff --git a/library/ZendAfi/View/Helper/CalendarContent.php b/library/ZendAfi/View/Helper/CalendarContent.php
index cf0ff91de1b7e44a94343c75a62565a887f4912b..6cc118d8b05fe531d2b43bab05d377a13488b0d7 100644
--- a/library/ZendAfi/View/Helper/CalendarContent.php
+++ b/library/ZendAfi/View/Helper/CalendarContent.php
@@ -35,17 +35,23 @@ class ZendAfi_View_Helper_CalendarContent extends ZendAfi_View_Helper_BaseHelper
    */
   public function calendarContent($calendar, $settings = []) {
     $this->param = $settings;
-
     $this->calendar = $calendar;
     $calendar->setTimeSource($this->getTimeSource());
     $calendar->initializeParams();
 
-    $html='<div class="calendar">';
-    $html.= $this->rendSelectionCategories();
-    $html.= $this->rendDateSelection($calendar);
-    $html.= $this->renderFilters($calendar);
-    $html.= $this->renderArticlesByList($calendar->getArticles());
-    return $html.='</div>';
+    return $this->view->div(['class' => 'calendar'],
+                            $this->_renderHTML($calendar, $settings));
+  }
+
+
+  protected function _renderHTML($calendar, $settings) {
+    $articles = $calendar->getArticles();
+
+    return
+      $this->rendSelectionCategories()
+      . $this->rendDateSelection($calendar)
+      . $this->renderFilters($calendar, $articles)
+      . $this->renderArticlesByList($articles);
   }
 
 
@@ -85,17 +91,18 @@ class ZendAfi_View_Helper_CalendarContent extends ZendAfi_View_Helper_BaseHelper
   }
 
 
-  protected function renderFilters() {
-    if (!$this->calendar->hasEnabledFilters())
+  protected function renderFilters($calendar, $articles) {
+    if (!$calendar->hasEnabledFilters())
       return '';
 
     $filter_settings = new Class_FilterSettings(new Class_Systeme_ModulesAccueil_Calendrier());
     $filter_settings
-      ->setFilters($this->calendar->getEnabledFilters())
-      ->setSelectedFilters($this->calendar->getActiveFilters())
+      ->setFilters($calendar->getEnabledFilters())
+      ->setSelectedFilters($calendar->getActiveFilters())
       ->setModelLabel('Article')
-      ->setUrlParams($this->calendar->getBaseUrl())
-      ->setCalendar($this->calendar);
+      ->setUrlParams($this->_getBaseUrl())
+      ->setCalendar($calendar)
+      ->setRenderedModels($articles);
 
     return $this->view->filters_Render($filter_settings);
   }
@@ -180,4 +187,9 @@ class ZendAfi_View_Helper_CalendarContent extends ZendAfi_View_Helper_BaseHelper
          ? $this->view->article_RenderTitleOnlyCalendarWithCatalogue($article)
          : $this->view->article_RenderTitleOnlyCalendar($article));
   }
+
+
+  protected function _getBaseUrl() {
+    return $this->calendar->getBaseUrl();
+  }
 }
diff --git a/library/ZendAfi/View/Helper/Filters/Element/Tag.php b/library/ZendAfi/View/Helper/Filters/Element/Tag.php
new file mode 100644
index 0000000000000000000000000000000000000000..64e0bb84e0be843681d8cc22f07a3de0e2183aa0
--- /dev/null
+++ b/library/ZendAfi/View/Helper/Filters/Element/Tag.php
@@ -0,0 +1,42 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+class ZendAfi_View_Helper_Filters_Element_Tag extends ZendAfi_View_Helper_Filters_Element {
+  public function __construct($custom_field_id = null) {
+    $this->_custom_field_id = 'tag';
+  }
+
+
+  public function elements() {
+    $all_tags = implode(';',
+                        $this
+                        ->_settings
+                        ->renderedModelsMap(
+                                            function($article)
+                                            {
+                                              return trim($article->getTags());
+                                            }));
+
+    $tags = array_unique(array_filter(explode(';', strtolower($all_tags))));
+    sort($tags);
+    return array_combine($tags, $tags);
+  }
+}
diff --git a/library/ZendAfi/View/Helper/TagArticleEvent.php b/library/ZendAfi/View/Helper/TagArticleEvent.php
index b063dfeaa6a0ac19c1c3da3f55a521adea93b5ec..923dda7d02f7ee13823d7645ec663b7abaed60e2 100644
--- a/library/ZendAfi/View/Helper/TagArticleEvent.php
+++ b/library/ZendAfi/View/Helper/TagArticleEvent.php
@@ -26,86 +26,12 @@ class ZendAfi_View_Helper_TagArticleEvent extends Zend_View_Helper_HtmlElement {
   public function tagArticleEvent($article) {
     return $article->hasEventsDebut()
       ? $this->view->tag('span',
-                         $this->formatEventString($article),
+                         $this->view->article_RenderEventTiming($article->getEventsDebut(),
+                                                                $article->getEventsFin(),
+                                                                $article->getAllDay(),
+                                                                $article->getPickDayAsArray()),
                          ['class' => 'calendar_event_date'])
       : '';
   }
-
-
-  protected function  getStartDate($article) {
-    return strtotime(substr($article->getEventsDebut(), 0, 10));
-  }
-
-
-  protected function  getEndDate($article) {
-    if (!$date_end=strtotime(substr($article->getEventsFin(), 0, 10)))
-      return $this->getStartDate($article);
-    return $date_end;
-  }
-
-  public function formatEventString($article) {
-    $date_start = $this->getStartDate($article);
-    $date_end = $this->getEndDate($article);
-
-    if ($date_start == $date_end) {
-      $event_string = strftime('%A %d %B %Y', $date_start);
-
-      if ($article->getAllDay())
-        return $event_string;
-
-      $time_start = date('H:i', strtotime($article->getEventsDebut()));
-      $time_end = date('H:i', strtotime($article->getEventsFin()));
-
-
-      return $event_string . (($time_start == $time_end)  ? $this->view->_(' à %s', $time_start)
-                                                          : $this->view->_(' de %s à %s', $time_start, $time_end));
-    }
-
-    $year_start = strftime('%Y', $date_start);
-    $year_end = strftime('%Y', $date_end);
-
-    $month_start = strftime('%B', $date_start);
-    $month_end = strftime('%B', $date_end);
-
-
-    if ($month_start == $month_end && $year_start == $year_end)
-      $month_start = '';
-
-    if ($year_start == $year_end)
-      $year_start = '';
-
-    $picked_days = $article->getPickDayAsArray();
-
-    if(empty($picked_days) || 7 == count($picked_days))
-       return $this->view->_('Du %s au %s',
-                             trim(strftime('%A %d', $date_start) . ' ' . $month_start . ' ' . $year_start),
-                             trim(strftime('%A %d', $date_end) . ' ' . $month_end . ' ' . $year_end));
-
-    $open_days = $this->getTextualDays($picked_days);
-
-    return $this->view->_('Les ' . $open_days . 's du %s au %s',
-                             trim(strftime('%e', $date_start) . ' ' . $month_start . ' ' . $year_start),
-                             trim(strftime('%e', $date_end) . ' ' . $month_end . ' ' . $year_end));
-  }
-
-
-  protected function getTextualDays($days) {
-    if(1 == count($days))
-      return  $this->numericDayToTextual($days[0]);
-
-    $last = array_pop($days);
-    $last = $this->numericDayToTextual($last);
-
-    $textual_days = '';
-    foreach($days as $day)
-      $textual_days.= $this->numericDayToTextual($day) . $this->view->_('s, ');
-
-    return substr($textual_days, 0, -2) . $this->view->_(' et ') . $last;
-  }
-
-
-  protected function numericDayToTextual($nb) {
-    return strftime('%A', strtotime('Sunday +' . $nb . ' days'));
-  }
 }
 ?>
diff --git a/library/templates/Intonation/Library/Widget/Carousel/Article/Definition.php b/library/templates/Intonation/Library/Widget/Carousel/Article/Definition.php
index fd90fe2f154a3331f4d087fa0e94d009f7977ef0..9c5900b7b4457e07eac5ba510d15a3c4c7bc71c5 100644
--- a/library/templates/Intonation/Library/Widget/Carousel/Article/Definition.php
+++ b/library/templates/Intonation/Library/Widget/Carousel/Article/Definition.php
@@ -28,12 +28,6 @@ class Intonation_Library_Widget_Carousel_Article_Definition extends Intonation_L
     SORT_SELECTION = 'Selection',
     SORT_RANDOM = 'Random',
 
-    SORT_COMMENTS_ASC = 'CommentCountAsc',
-    SORT_COMMENTS = 'CommentCount',
-
-    SORT_TITLE_ASC = 'TitleAsc',
-    SORT_TITLE_DESC = 'TitleDesc',
-
     SORT_CREATION_ASC = 'DateCreationAsc',
     SORT_CREATION_DESC = 'DateCreationDesc',
 
diff --git a/library/templates/Intonation/Library/Widget/Carousel/Article/Form.php b/library/templates/Intonation/Library/Widget/Carousel/Article/Form.php
index 141779259e0db73a4070e24980cf794ebffe722b..148898492f3f0c7d1b784c934565e9c4103cbf48 100644
--- a/library/templates/Intonation/Library/Widget/Carousel/Article/Form.php
+++ b/library/templates/Intonation/Library/Widget/Carousel/Article/Form.php
@@ -58,12 +58,12 @@ class Intonation_Library_Widget_Carousel_Article_Form extends Intonation_Library
             Intonation_Library_Widget_Carousel_Article_Definition::SORT_PUBLICATION_ASC => $this->_('Date de publication croissant'),
             Intonation_Library_Widget_Carousel_Article_Definition::SORT_PUBLICATION_DESC => $this->_('Date de publication décroissant'),
 
-            Intonation_Library_Widget_Carousel_Article_Definition::SORT_COMMENTS_ASC => $this->_('Nombre d\'avis croissant'),
-            Intonation_Library_Widget_Carousel_Article_Definition::SORT_COMMENTS => $this->_('Nombre d\'avis décroissant'),
+            Class_Article_Loader::ORDER_COMMENTS_ASC => $this->_('Nombre d\'avis croissant'),
+            Class_Article_Loader::ORDER_COMMENTS  => $this->_('Nombre d\'avis décroissant'),
 
             Intonation_Library_Widget_Carousel_Article_Definition::SORT_SELECTION => $this->_('Sélection'),
 
-            Intonation_Library_Widget_Carousel_Article_Definition::SORT_TITLE_ASC => $this->_('Titre A-z'),
-            Intonation_Library_Widget_Carousel_Article_Definition::SORT_TITLE_DESC => $this->_('Titre Z-a')];
+            Class_Article_Loader::ORDER_TITLE_ASC => $this->_('Titre A-z'),
+            Class_Article_Loader::ORDER_TITLE_DESC => $this->_('Titre Z-a')];
   }
 }
\ No newline at end of file
diff --git a/library/templates/Intonation/View/CalendarContent.php b/library/templates/Intonation/View/CalendarContent.php
index 46b22bbf913efe38a22df9fcee137f47b21aa3f4..1d52a63a48a9b86c9619149ae571149939e56d30 100644
--- a/library/templates/Intonation/View/CalendarContent.php
+++ b/library/templates/Intonation/View/CalendarContent.php
@@ -21,47 +21,22 @@
 
 
 class Intonation_View_CalendarContent extends ZendAfi_View_Helper_CalendarContent {
-
-  public function calendarContent($calendar, $settings = []) {
+  protected function _renderHTML($calendar, $settings) {
     Class_ScriptLoader::getInstance()->addOPACScript('calendrier');
 
-    $this->param = $settings;
-    $this->calendar = $calendar;
-
-    $this->calendar->setTimeSource($this->getTimeSource());
-    $this->calendar->initializeParams();
+    if (isset($settings['layout'])
+        &&
+        ($settings['layout'] == Intonation_Library_Widget_Carousel_Agenda_Definition::CALENDAR))
+      return $this->view->calendar_Table($calendar);
 
-    $html = ((isset($settings['layout'])) && ( $settings['layout'] == Intonation_Library_Widget_Carousel_Agenda_Definition::CALENDAR)
-             ? [$this->view->calendar_Table($this->calendar)]
-             : [$this->renderFilters($calendar),
-                (new Intonation_Library_Widget_Carousel_Agenda_View($this->calendar->getIdModule(),
-                                                                    $settings))
+    $widget = (new Intonation_Library_Widget_Carousel_Agenda_View($calendar->getIdModule(),
+                                                                  $settings))
                 ->setView($this->view)
-                ->setCalendar($this->calendar)
-                ->renderElements()]);
-
-    return $this->view->div(['class' => 'calendar'], implode($html));
-  }
-
-
-  protected function renderFilters() {
-    if (!$this->calendar->hasEnabledFilters())
-      return '';
-
-    $filter_settings = new Class_FilterSettings(new Class_Systeme_ModulesAccueil_Calendrier());
-
-    $filter_settings
-      ->setFilters($this->calendar->getEnabledFilters())
-      ->setSelectedFilters($this->calendar->getActiveFilters())
-      ->setModelLabel('Article')
-      ->setUrlParams($this->_getBaseUrl())
-      ->setCalendar($this->calendar);
-
-    return $this->view->filters_Render($filter_settings);
-  }
-
+                ->setCalendar($calendar);
 
-  protected function _getBaseUrl() {
-    return $this->calendar->getBaseUrl();
+    return
+      $this->renderFilters($calendar, $calendar->getArticles())
+      .
+      $widget->renderElements();
   }
 }
diff --git a/library/templates/Intonation/View/RenderArticle.php b/library/templates/Intonation/View/RenderArticle.php
index 32aa3cdd1daf201e511f007d2d033d2ef3df9f7e..32bb9f52566c80518ea588da1ddbc6db3660ffff 100644
--- a/library/templates/Intonation/View/RenderArticle.php
+++ b/library/templates/Intonation/View/RenderArticle.php
@@ -49,6 +49,10 @@ class Intonation_View_RenderArticle extends ZendAfi_View_Helper_BaseHelper {
                                  ['class' => 'card-text']),
                      $location_content];
 
+
+    if (Class_AdminVar::isModuleEnabled('ENABLE_ARTICLES_TIMINGS'))
+      $body_content []= $this->view->article_RenderEventTimings($article);
+
     $body = $this->_tag('div',
                         implode($body_content),
                         ['class' => 'card-body']);
diff --git a/tests/application/modules/admin/controllers/CmsControllerTest.php b/tests/application/modules/admin/controllers/CmsControllerTest.php
index 65cb5d945015926cc3e32515470d72fad85e697c..8859c19d9ca356a760a426ace80a63d3f318739f 100644
--- a/tests/application/modules/admin/controllers/CmsControllerTest.php
+++ b/tests/application/modules/admin/controllers/CmsControllerTest.php
@@ -23,6 +23,7 @@ require_once 'AdminAbstractControllerTestCase.php';
 
 abstract class CmsControllerTestCase extends Admin_AbstractControllerTestCase {
   protected
+    $_storm_default_to_volatile = true,
     $concert,  /** @var Class_Article */
     $lieu_bonlieu,
     $lieu_arcadium,
@@ -34,8 +35,6 @@ abstract class CmsControllerTestCase extends Admin_AbstractControllerTestCase {
     $_cat_a_la_une,
     $_cat_atelier;
 
-  protected $_storm_default_to_volatile = true;
-
 
   public function setUp() {
     parent::setUp();
@@ -3086,4 +3085,4 @@ class CmsControllerEditArticleWithQueryTest extends CmsControllertestCase {
   public function formActionShouldContainsId4() {
     $this->assertXPath('//form[@action="/admin/cms/edit/id/4"]');
   }
-}
\ No newline at end of file
+}
diff --git a/tests/application/modules/admin/controllers/WidgetControllerTest.php b/tests/application/modules/admin/controllers/WidgetControllerTest.php
index 9d3a32966eb45138f0f190694be105ef8d1d3fad..e3703dbb5d11aa1a1e031c704afd2fc0eb236e7e 100644
--- a/tests/application/modules/admin/controllers/WidgetControllerTest.php
+++ b/tests/application/modules/admin/controllers/WidgetControllerTest.php
@@ -381,8 +381,8 @@ class WidgetControllerCalendarTest extends WidgetControllerDispatchWidgetConfigu
 
 
   /** @test */
-  public function secondListShouldContainsThreeElements() {
-    $this->assertXPathCount('//div[@id="input_enabled_filters"]/div[2]/ul/li', 3);
+  public function secondListShouldContainsFourElements() {
+    $this->assertXPathCount('//div[@id="input_enabled_filters"]/div[2]/ul/li', 4);
   }
 
 
@@ -398,6 +398,12 @@ class WidgetControllerCalendarTest extends WidgetControllerDispatchWidgetConfigu
   }
 
 
+  /** @test */
+  public function secondListShouldContainsEtiquetteForTag() {
+    $this->assertXPathContentContains('//div[@id="input_enabled_filters"]/div[2]/ul/li[@data-value="tag"]', 'Étiquette');
+  }
+
+
   /** @test */
   public function firstListShouldContainsThreeElements() {
     $this->assertXPathCount('//div[@id="input_enabled_filters"]/div[1]/ul/li', 3);
@@ -2820,4 +2826,4 @@ class WidgetControllerAddActionWithExisting2Columns
                          'styles_reload' => '0'],
                         $preferences);
   }
-}
\ No newline at end of file
+}
diff --git a/tests/application/modules/opac/controllers/CmsControllerCalendarActionTest.php b/tests/application/modules/opac/controllers/CmsControllerCalendarActionTest.php
index 0a2fd59e717f6572b707077962fff06ff7dd86a0..397d59cab3dd8129a017ddb1aa1a597cf6a258c0 100644
--- a/tests/application/modules/opac/controllers/CmsControllerCalendarActionTest.php
+++ b/tests/application/modules/opac/controllers/CmsControllerCalendarActionTest.php
@@ -38,12 +38,12 @@ abstract class CmsControllerCalendarActionTestCase extends AbstractControllerTes
                                    'rss_avis' => false,
                                    'id_categorie' => '12-2',
                                    'display_cat_select' => true,
-                                   'enabled_filters' => 'day;date;place;custom_field_2;custom_field_20;zork',
+                                   'enabled_filters' => 'day;date;place;custom_field_2;custom_field_20;zork;tag',
                                    'display_event_info' => 'none']]],
        'options' =>   []];
 
 
-    $this->fixture('Class_Profil',
+    $this->fixture(Class_Profil::class,
                    ['id' => 3,
                     'browser' => 'opac',
                     'libelle' => 'Rendez-vous',
@@ -52,21 +52,22 @@ abstract class CmsControllerCalendarActionTestCase extends AbstractControllerTes
     Class_AdminVar::newInstanceWithId('CACHE_ACTIF',
                                       ['valeur' => '1']);
 
-    $this->_nanook2 = $this->fixture('Class_Article',
+    $this->_nanook2 = $this->fixture(Class_Article::class,
                                      ['id' => 4,
                                       'titre' => 'Nanook 2 en prod !',
                                       'contenu' => 'youpi !',
                                       'events_debut' => '2011-02-17',
-                                      'events_fin' => '2011-02-22']);
+                                      'events_fin' => '2011-02-22',
+                                      'tags' => 'logiciel']);
 
-    $this->onLoaderOfModel('Class_Article')
+    $this->onLoaderOfModel(Class_Article::class)
          ->whenCalled('getArticlesByPreferences')
          ->answers([$this->_nanook2]);
   }
 
 
   public function setupCustomFields() {
-    $theme = $this->fixture('Class_CustomField',
+    $theme = $this->fixture(Class_CustomField::class,
                             ['id' => 2,
                              'priority' => 3,
                              'label' => 'Theme',
@@ -74,7 +75,7 @@ abstract class CmsControllerCalendarActionTestCase extends AbstractControllerTes
                              'options_list' => 'sigb;opac',
                              'model' => 'Article']);
 
-    $multi_options = $this->fixture('Class_CustomField',
+    $multi_options = $this->fixture(Class_CustomField::class,
                                     ['id' => 20,
                                      'priority' => 3,
                                      'label' => 'Options "classieuses"',
@@ -82,13 +83,13 @@ abstract class CmsControllerCalendarActionTestCase extends AbstractControllerTes
                                      'options_list' => 'wifi;restauration;projection',
                                      'model' => 'Article']);
 
-    $this->fixture('Class_CustomField_Value',
+    $this->fixture(Class_CustomField_Value::class,
                    ['id' => 59,
                     'custom_field_id' => $theme->getId(),
                     'model_id' => $this->_nanook2->getId(),
                     'value' => 'sigb']);
 
-    $this->fixture('Class_CustomField_Value',
+    $this->fixture(Class_CustomField_Value::class,
                    ['id' => 5,
                     'custom_field_id' => $multi_options->getId(),
                     'model_id' => $this->_nanook2->getId(),
@@ -139,23 +140,24 @@ class CmsControllerCalendarActionLanguageEnTest extends CmsControllerCalendarAct
 
   /** @test */
   function withLocaleEnMonthShouldBeFebruary() {
-    $this->assertXPathContentContains('//td[@class="calendar_title_month"]/a', "february", $this->_response->getBody());
+    $this->assertXPathContentContains('//td[@class="calendar_title_month"]/a', "february");
   }
 
 
   /** @test **/
   function calendarShouldContains6AwithClassCalendarTitleMonthClickable() {
-    $this->assertXPathCount('//a[@class="calendar_title_month_clickable"]', 6);
+    $this->assertXPathCount('//a[@class="calendar_title_month_clickable"]', 6, $this->_response->getBody());
   }
 }
 
 
 
+
 class CmsControllerCalendarActionWithMultipleFiltersTest extends CmsControllerCalendarActionTestCase {
   protected $_permaculture;
   public function setupCustomFields() {
     Class_CustomField_Meta::beVolatile();
-    $theme = $this->fixture('Class_CustomField',
+    $theme = $this->fixture(Class_CustomField::class,
                             ['id' => 2,
                              'priority' => 3,
                              'label' => 'Theme',
@@ -163,7 +165,7 @@ class CmsControllerCalendarActionWithMultipleFiltersTest extends CmsControllerCa
                              'options_list' => 'culture;bd;logiciels libres',
                              'model' => 'Article']);
 
-    $multi_options = $this->fixture('Class_CustomField',
+    $multi_options = $this->fixture(Class_CustomField::class,
                                     ['id' => 20,
                                      'priority' => 3,
                                      'label' => 'Options',
@@ -171,7 +173,7 @@ class CmsControllerCalendarActionWithMultipleFiltersTest extends CmsControllerCa
                                      'options_list' => 'wifi;jardinage;projection',
                                      'model' => 'Article']);
 
-    $this->fixture('Class_CustomField_Value',
+    $this->fixture(Class_CustomField_Value::class,
                    ['id' => 59,
                     'custom_field_id' => $theme->getId(),
                     'model_id' => $this->_nanook2->getId(),
@@ -179,13 +181,13 @@ class CmsControllerCalendarActionWithMultipleFiltersTest extends CmsControllerCa
 
 
 
-    $this->fixture('Class_CustomField_Value',
+    $this->fixture(Class_CustomField_Value::class,
                    ['id' => 5,
                     'custom_field_id' => $multi_options->getId(),
                     'model_id' => $this->_permaculture->getId(),
                     'value' => ';jardinage;']);
 
-    $this->fixture('Class_CustomField_Value',
+    $this->fixture(Class_CustomField_Value::class,
                    ['id' => 2,
                     'custom_field_id' => $theme->getId(),
                     'model_id' => $this->_permaculture->getId(),
@@ -195,12 +197,13 @@ class CmsControllerCalendarActionWithMultipleFiltersTest extends CmsControllerCa
 
   public function setUp() {
     parent::setUp();
-    $this->_permaculture = $this->fixture('Class_Article',
+    $this->_permaculture = $this->fixture(Class_Article::class,
                                           ['id' => 5,
                                            'titre' => 'Liberons la permaculture !',
                                            'contenu' => 'Venez avec vos poireaux !',
                                            'events_debut' => '2011-02-17',
-                                           'events_fin' => '2011-02-22']);
+                                           'events_fin' => '2011-02-22',
+                                           'tags' => '']);
     $this->setupCustomFields();
 
     $common_preferences = ['display_order' => 'EventDebut',
@@ -261,7 +264,6 @@ class CmsControllerCalendarActionWithMultipleFiltersTest extends CmsControllerCa
   }
 
 
-
   /** @test */
   public function articleNanookShouldBeDisplayWithLLSelected() {
     $this->dispatch('/cms/calendar/id_profil/3/id_module/1/date/2014-07/custom_field_2/logiciels libres/render/ajax', true);
@@ -281,7 +283,7 @@ abstract class CmsControllerCalendarActionWithFiltersTestCase
 
   public function setUp() {
     parent::setUp();
-    $this->_opac4 = $this->fixture('Class_Article',
+    $this->_opac4 = $this->fixture(Class_Article::class,
                                    ['id' => 5,
                                     'titre' => 'OPAC 4 en prod !',
                                     'contenu' => 'youpi !',
@@ -331,7 +333,7 @@ class CmsControllerCalendarActionWithFiltersDateTest
   public function setupCustomFields() {
     parent::setupCustomFields();
 
-    $this->fixture('Class_CustomField_Value',
+    $this->fixture(Class_CustomField_Value::class,
                    ['id' => 2,
                     'custom_field_id' => 2,
                     'model_id' => 5,
@@ -421,8 +423,7 @@ class CmsControllerCalendarActionWithFiltersDateTest
 
   /** @test */
   public function linkNextMonthShouldContainsDayParameterAndDateParameter() {
-    $this->assertXPath('//a[@class="calendar_title_month_clickable"][contains(@href,"/day/2014-08")][contains(@href, "/date/2014-08")]',
-                       $this->_response->getBody());
+    $this->assertXPath('//a[@class="calendar_title_month_clickable"][contains(@href,"/day/2014-08")][contains(@href, "/date/2014-08")]');
   }
 
 
@@ -440,8 +441,7 @@ class CmsControllerCalendarActionWithFiltersDateTest
 
   /** @test */
   public function linkInsideCalendarShouldContainsDayButNoDateParameters() {
-    $this->assertXPath('//a[contains(@class,"day_clickable")][contains(@href,"/day/2014-07-17")][not(contains(@href, "/date/"))]',
-                       $this->_response->getBody());
+    $this->assertXPath('//a[contains(@class,"day_clickable")][contains(@href,"/day/2014-07-17")][not(contains(@href, "/date/"))]');
   }
 }
 
@@ -486,7 +486,7 @@ class CmsControllerCalendarActionWithFiltersDateAndNoCalendarTest
 
 
 
-class CmsControllerCalendarActionWithFiltersDayTest  extends CmsControllerCalendarActionWithFiltersTestCase {
+class CmsControllerCalendarActionWithFiltersDayTest extends CmsControllerCalendarActionWithFiltersTestCase {
   public function setUp() {
     parent::setUp();
 
@@ -533,21 +533,21 @@ class CmsControllerCalendarActionWithDayTest extends AbstractControllerTestCase
                     'options' =>  []];
 
 
-    $this->fixture('Class_Profil',
+    $this->fixture(Class_Profil::class,
                    ['id' => 3,
                     'browser' => 'opac',
                     'libelle' => 'Plop',
                     'cfg_accueil' => $cfg_accueil]);
 
 
-    $this->fixture('Class_Article',
+    $this->fixture(Class_Article::class,
                    ['id' => 5,
                     'titre' => 'News of the 15th September',
                     'contenu' => 'youpi !',
                     'events_debut' => '2014-09-15',
                     'events_fin' => '2014-09-15']);
 
-    $this->fixture('Class_Article',
+    $this->fixture(Class_Article::class,
                    ['id' => 2,
                     'titre' => 'News of the 30th September',
                     'contenu' => 'youpi !',
@@ -558,7 +558,7 @@ class CmsControllerCalendarActionWithDayTest extends AbstractControllerTestCase
     ZendAfi_View_Helper_CalendarContent::setTimeSource($time_source);
     ZendAfi_View_Helper_Calendar_Table::setTimeSource($time_source);
 
-    $this->onLoaderOfModel('Class_Article')
+    $this->onLoaderOfModel(Class_Article::class)
          ->whenCalled('getArticlesByPreferences')
          ->with(['display_order' => 'EventDebut',
                  'id_categorie' => '',
@@ -664,11 +664,11 @@ class CmsControllerCalendarActionHeaderTest extends AbstractControllerTestCase {
     ];
 
 
-    $this->onLoaderOfModel('Class_Article')
+    $this->onLoaderOfModel(Class_Article::class)
          ->whenCalled('getArticlesByPreferences')
          ->answers([]);
 
-    $this->fixture('Class_Profil',
+    $this->fixture(Class_Profil::class,
                    ['id' => 4,
                     'browser' => 'opac',
                     'libelle' => 'Rendez-vous',
@@ -715,13 +715,13 @@ class CmsControllerCalendarActionAjaxLinkTest extends AbstractControllerTestCase
     ];
 
 
-    $this->fixture('Class_Profil',
+    $this->fixture(Class_Profil::class,
                    ['id' => 5,
                     'browser' => 'opac',
                     'libelle' => 'Rendez-vous',
                     'cfg_accueil' => $cfg_accueil]);
 
-    $articles = [$this->fixture('Class_Article',
+    $articles = [$this->fixture(Class_Article::class,
                                 ['id' => 25,
                                  'titre' => 'News of the 30th September',
                                  'contenu' => 'youpi !',
@@ -732,7 +732,7 @@ class CmsControllerCalendarActionAjaxLinkTest extends AbstractControllerTestCase
     ZendAfi_View_Helper_CalendarContent::setTimeSource($time_source);
     ZendAfi_View_Helper_Calendar_Table::setTimeSource($time_source);
 
-    $this->onLoaderOfModel('Class_Article')
+    $this->onLoaderOfModel(Class_Article::class)
          ->whenCalled('getArticlesByPreferences')
          ->answers($articles);
 
@@ -866,7 +866,7 @@ class CmsControllerCalendarActionIcalExportSimpleTest
   extends CmsControllerCalendarActionIcalExportTestCase {
 
   protected function _prepareFixtures () {
-    $capsule = $this->fixture('Class_Article',
+    $capsule = $this->fixture(Class_Article::class,
                               ['id' => 42,
                                'titre' => 'CAPSULE TEMPORELLE 2017-2054',
                                'contenu' => 'Curieux envers l\'avenir ?',
@@ -1156,7 +1156,7 @@ class CmsControllerCalendarActionIcalExportSimpleRecurrentTest
   extends CmsControllerCalendarActionIcalExportTestCase {
 
   protected function _prepareFixtures () {
-    $capsule = $this->fixture('Class_Article',
+    $capsule = $this->fixture(Class_Article::class,
                               ['id' => 42,
                                'titre' => 'CAPSULE TEMPORELLE 2017-2054',
                                'contenu' => 'Curieux envers l\'avenir ?',
@@ -1199,7 +1199,7 @@ class CmsControllerCalendarActionIcalExportRecurrentAllDayTest
   extends CmsControllerCalendarActionIcalExportTestCase {
 
   protected function _prepareFixtures () {
-    $opac4 = $this->fixture('Class_Article',
+    $opac4 = $this->fixture(Class_Article::class,
                             ['id' => 5,
                              'titre' => 'OPAC 4 en prod !',
                              'contenu' => '<h3>youpi &amp; oui c&#39;est beau !</h3><img src="/userfiles/images/youpi.png">',
@@ -1217,7 +1217,7 @@ class CmsControllerCalendarActionIcalExportRecurrentAllDayTest
   public function setupCustomFields() {
     parent::setupCustomFields();
 
-    $this->fixture('Class_CustomField_Value',
+    $this->fixture(Class_CustomField_Value::class,
                    ['id' => 2,
                     'custom_field_id' => 2,
                     'model_id' => 5,
@@ -1317,7 +1317,7 @@ class CmsControllerCalendarActionWithOutDateTest extends AbstractControllerTestC
     ZendAfi_View_Helper_CalendarContent::setTimeSource($time_source);
     ZendAfi_View_Helper_Calendar_Table::setTimeSource($time_source);
 
-    $this->onLoaderOfModel('Class_Article')
+    $this->onLoaderOfModel(Class_Article::class)
          ->whenCalled('getArticlesByPreferences')
          ->with(['display_order' => 'EventDebut',
                  'id_categorie' => '',
@@ -1342,7 +1342,7 @@ class CmsControllerCalendarActionWithOutDateTest extends AbstractControllerTestC
                  'custom_fields' => [],
                  'published' => true,
                  'event_end_after' => '2014-09-02'])
-         ->answers([$this->fixture('Class_Article',
+         ->answers([$this->fixture(Class_Article::class,
                                    ['id' => 1,
                                     'titre' => 'Kitchen',
                                     'contenu' => 'Cook'])])
@@ -1391,7 +1391,7 @@ class CmsControllerCalendarActionIcalExportOneArticleByIdTest
   extends CmsControllerCalendarActionIcalExportTestCase {
 
   protected function _prepareFixtures () {
-    $this->fixture('Class_Article',
+    $this->fixture(Class_Article::class,
                    ['id' => 5,
                     'titre' => 'OPAC 4 en prod !',
                     'contenu' => '<h3>youpi &amp; oui c&#39;est beau !</h3><img src="/userfiles/images/youpi.png">',
@@ -1458,7 +1458,7 @@ class CmsControllerCalenderWithAllCatSelectedAndIdCategorieSetTest extends CmsCo
   public function setUp() {
     parent::setUp();
 
-    $this->onLoaderOfModel('Class_Article')
+    $this->onLoaderOfModel(Class_Article::class)
          ->whenCalled('getArticlesByPreferences')
          ->with(['display_order' => 'EventDebut',
                  'id_categorie' => '12-2',
@@ -1511,13 +1511,13 @@ class CmsControllerCalenderWithAllCatSelectedAndIdCategorieEmptyTest extends Cms
                                    'display_event_info' => 'none']]],
        'options' =>   []];
 
-    $this->fixture('Class_Profil',
+    $this->fixture(Class_Profil::class,
                    ['id' => 3,
                     'browser' => 'opac',
                     'libelle' => 'Rendez-vous',
                     'cfg_accueil' => $this->cfg_accueil]);
 
-    $this->onLoaderOfModel('Class_Article')
+    $this->onLoaderOfModel(Class_Article::class)
          ->whenCalled('getArticlesByPreferences')
          ->with(['display_order' => 'EventDebut',
                  'id_categorie' => '',
@@ -1545,4 +1545,52 @@ class CmsControllerCalenderWithAllCatSelectedAndIdCategorieEmptyTest extends Cms
   function shouldDisplayArticleNanook2() {
     $this->assertXPathContentContains('//div', 'Nanook 2 en prod !');
   }
-}
\ No newline at end of file
+}
+
+
+
+
+class CmsControllerCalendarActionWithFiltersTagTest extends CmsControllerCalendarActionTestCase {
+  public function setUp() {
+    parent::setUp();
+
+    Class_Article::getLoader()
+      ->whenCalled('getArticlesByPreferences')
+      ->with(['tag' => 'logiciel',
+              'display_order' => 'EventDebut',
+              'id_categorie' => '12-2',
+              'events_only' => true,
+              'event_date' => '2011-02',
+              'id_bib' => 0,
+              'id_lieu' => '',
+              'custom_fields' => [],
+              'published' => false])
+      ->answers([$this->_nanook2])
+
+      ->whenCalled('getArticlesByPreferences')
+      ->with(['tag' => 'logiciel',
+              'display_order' => 'EventDebut',
+              'id_categorie' => '12-2',
+              'events_only' => true,
+              'event_date' => '',
+              'id_bib' => 0,
+              'id_lieu' => '',
+              'custom_fields' => [],
+              'published' => false,
+              'event_start_after' => '2011-02',
+              'event_end_after' => '',
+              'limit' => 3])
+      ->answers([$this->_nanook2])
+
+      ->beStrict();
+
+    $this->dispatch('/cms/calendar/id_profil/3/id_module/1/tag/logiciel/date/2011-02/render/ajax', true);
+  }
+
+
+  /** @test */
+  public function responseShouldContainsArticleNanook() {
+    $this->assertXPathContentContains('//a[@class="calendar_event_title"]', 'Nanook 2 en prod !');
+  }
+
+}
diff --git a/tests/application/modules/opac/controllers/CmsControllerTest.php b/tests/application/modules/opac/controllers/CmsControllerTest.php
index 30edc8b27dcac5c62badfe64fbc7aa4665b14920..e85ebc30ea4b0b0c3b4f15091b1a9f600440e5e2 100644
--- a/tests/application/modules/opac/controllers/CmsControllerTest.php
+++ b/tests/application/modules/opac/controllers/CmsControllerTest.php
@@ -329,8 +329,6 @@ abstract class AbstractCmsControllerArticleViewByDateTest extends AbstractContro
                                                                 'libelle' => 'Alimentaire'])])
     ];
 
-    $articles =  Class_Article::getLoader()->sortArticles($articles, $this->display_order);
-
     $prefs_module_calendar = ['titre' => "Calendrier des animations",
                               'event_date' => '2011-09-03',
                               'id_bib' => null,
@@ -2076,4 +2074,4 @@ class CmsControllerWithArticleWithCubeKioskTest extends CmsControllerWithArticle
     $this->assertXPathContentContains('//div[contains(@class, "boite kiosque")]/div/a',
                                       'Boite kiosque');
   }
-}
\ No newline at end of file
+}
diff --git a/tests/application/modules/opac/controllers/ProfilOptionsControllerTest.php b/tests/application/modules/opac/controllers/ProfilOptionsControllerTest.php
index c77e2a42e0a17633870e444eba7bf7db575c4216..ba4e11711669689ba1f66799fb17973ad3cbc215 100644
--- a/tests/application/modules/opac/controllers/ProfilOptionsControllerTest.php
+++ b/tests/application/modules/opac/controllers/ProfilOptionsControllerTest.php
@@ -2501,8 +2501,8 @@ class ProfilOptionsControllerProfilBoiteCalendarWithFilterOnMonthsTest extends P
 
 
 
-class ProfilOptionsControllerProfilBoiteCalendarWithNoFilterTest extends ProfilOptionsControllerProfilBoiteCalendarWithFilterTestCase {
 
+class ProfilOptionsControllerProfilBoiteCalendarWithNoFilterTest extends ProfilOptionsControllerProfilBoiteCalendarWithFilterTestCase {
   public function setup() {
     parent::setup();
 
@@ -2521,6 +2521,7 @@ class ProfilOptionsControllerProfilBoiteCalendarWithNoFilterTest extends ProfilO
     $this->dispatch('/opac');
   }
 
+
   /** @test **/
   public function boiteCalendarShouldBeDisplay() {
     $this->assertXPath('//div[contains(@class,"calendar")]');
@@ -2529,14 +2530,15 @@ class ProfilOptionsControllerProfilBoiteCalendarWithNoFilterTest extends ProfilO
 
   /** @test **/
   public function articleFromOctoberShouldBePresent() {
-    $this->assertXPath('//article//header//a[contains(text(), "News from October")]');
+    $this->assertXPath('//article//header//a[contains(text(), "News from October")]',
+                       $this->_response->getBody());
   }
 }
 
 
 
-class ProfilOptionsControllerWidgetCalendarWithDisplayFullPageDeactivateShouldUseAjaxLink extends ProfilOptionsControllerProfilBoiteCalendarWithFilterTestCase {
 
+class ProfilOptionsControllerWidgetCalendarWithDisplayFullPageDeactivateShouldUseAjaxLink extends ProfilOptionsControllerProfilBoiteCalendarWithFilterTestCase {
   public function setUp() {
     parent::setUp();
 
@@ -2919,4 +2921,4 @@ class ProfilOptionsControllerRecentSitoTest extends ProfilOptionsControllerProfi
     $this->dispatch('/opac/index/index/clef/zork?id_profil=12', true);
     $this->assertXPathCount('//div[@class="sitotheque"]', 2);
   }
-}
\ No newline at end of file
+}
diff --git a/tests/db/UpgradeDBTest.php b/tests/db/UpgradeDBTest.php
index 81ec25679178025ea7aa55c02d4093e9b367d664..47a8a956cd2eddcdf0896a6236eba5c1b2ff252a 100644
--- a/tests/db/UpgradeDBTest.php
+++ b/tests/db/UpgradeDBTest.php
@@ -3953,6 +3953,33 @@ class UpgradeDB_414_Test extends UpgradeDBTestCase {
                         current($this
                                 ->query('select valeur from bib_admin_var where clef="STATUS_REPORT_PUSH_URL"')
                                 ->fetch()));
+  }
+}
+
+
+
+
+class UpgradeDB_415_Test extends UpgradeDBTestCase {
+  public function prepare() {
+    $this->dropTable('cms_article_timings');
+  }
+
+  public function datas() {
+    return
+      [
+       ['id',  'int(11) unsigned'],
+       ['article_id', 'int(11) unsigned'],
+       ['start', 'datetime'],
+       ['end', 'datetime']
+      ];
+  }
 
+
+  /**
+   * @test
+   * @dataProvider datas
+   */
+  public function tableCmsArticleTimingShouldHaveField($field, $type) {
+    $this->assertFieldType('cms_article_timings', $field, $type);
   }
 }
\ No newline at end of file
diff --git a/tests/library/Class/ArticleLoaderTest.php b/tests/library/Class/ArticleLoaderTest.php
index 7478ebc65ab1c12f66170594cad3f74f0aaed97e..4d9486df50acdd7b20a75d4ce7155f208c04d28e 100644
--- a/tests/library/Class/ArticleLoaderTest.php
+++ b/tests/library/Class/ArticleLoaderTest.php
@@ -21,10 +21,17 @@
 
 
 abstract class ArticleLoaderGetArticlesByPreferencesTestCase extends ModelTestCase {
+  const WHERE_VISIBLE_CLAUSE =
+    '((DEBUT IS NULL) OR (DEBUT <= CURDATE())) AND ((FIN IS NULL) OR (FIN >= CURDATE()))';
+
   public function setUp() {
-    $this->select = new Zend_Db_Table_Select(new Storm_Model_Table(array('name' => 'cms_article')));
+    Class_AdminVar::set('ENABLE_ARTICLES_TIMINGS', 0);
+
+    $this->select = new Zend_Db_Table_Select(
+                      new Storm_Model_Table(['name' => 'cms_article']));
 
-    $this->tbl_articles = $this->_buildTableMock('Class_Article', array('select', 'fetchAll'));
+    $this->tbl_articles = $this->_buildTableMock(Class_Article::class,
+                                                 ['select', 'fetchAll']);
     $this->tbl_articles
       ->expects($this->any())
       ->method('select')
@@ -38,10 +45,10 @@ abstract class ArticleLoaderGetArticlesByPreferencesTestCase extends ModelTestCa
     Class_ArticleCategorie::getLoader()
       ->newInstanceWithId(2)
       ->setLibelle('Fêtes')
-      ->setSousCategories(array(Class_ArticleCategorie::getLoader()
-                                ->newInstanceWithId(4)
-                                ->setLibelle('Exotiques')
-                                ->setSousCategories(array())));
+      ->setSousCategories([Class_ArticleCategorie::getLoader()
+                           ->newInstanceWithId(4)
+                           ->setLibelle('Exotiques')
+                           ->setSousCategories([])]);
 
   }
 
@@ -55,14 +62,16 @@ abstract class ArticleLoaderGetArticlesByPreferencesTestCase extends ModelTestCa
              'DATE_CREATION' => '2011-04-02',
              'DEBUT' => '2011-10-02',
              'FIN' => '2011-10-22',
-             'EVENTS_DEBUT' => '2011-10-20'],
+             'EVENTS_DEBUT' => '2011-10-20',
+             'TAGS' => 'Fête;Pomme'],
 
             ['ID_ARTICLE' => 18,
              'ID_CAT' => 2,
              'TITRE' => 'Fête de la poire',
              'DATE_CREATION' => '2010-03-25',
              'DEBUT' => '2010-03-25',
-             'EVENTS_DEBUT' => '2010-03-25'],
+             'EVENTS_DEBUT' => '2010-03-25',
+             'TAGS' => 'Fête;Poire'],
 
             ['ID_ARTICLE' => 55,
              'ID_CAT' => 4,
@@ -70,7 +79,8 @@ abstract class ArticleLoaderGetArticlesByPreferencesTestCase extends ModelTestCa
              'DEBUT' => '2011-01-01',
              'DATE_CREATION' => '2011-05-01',
              'EVENTS_DEBUT' => '2011-03-25',
-             'EVENTS_FIN' => '2011-03-27']
+             'EVENTS_FIN' => '2011-03-27',
+             'TAGS' => 'Fête;Banane']
     ];
   }
 
@@ -78,21 +88,18 @@ abstract class ArticleLoaderGetArticlesByPreferencesTestCase extends ModelTestCa
   public function getArticles($prefs) {
     return Class_Article::getLoader()->getArticlesByPreferences($prefs);
   }
-}
-
-
-
 
-class ArticleLoaderGetArticlesByPreferencesTest extends ArticleLoaderGetArticlesByPreferencesTestCase {
-  const WHERE_VISIBLE_CLAUSE =
-    '((DEBUT IS NULL) OR (DEBUT <= CURDATE())) AND ((FIN IS NULL) OR (FIN >= CURDATE()))';
 
   public function assertSelect($expected) {
     $this->assertEquals("SELECT `cms_article`.* FROM `cms_article` ".$expected,
                         str_replace("\n", "", $this->select->assemble()));
   }
+}
 
 
+
+
+class ArticleLoaderGetArticlesByPreferencesTest extends ArticleLoaderGetArticlesByPreferencesTestCase {
   /** @test */
   function withNoSelectionAnd5ArticlesDisplayedOrderedByDate() {
     $articles = $this->getArticles(array('display_order' => 'DateCreationDesc',
@@ -197,9 +204,6 @@ class ArticleLoaderGetArticlesByPreferencesTest extends ArticleLoaderGetArticles
   }
 
 
-
-
-
   /** @test */
   function withArticleSelectionRandomAndNbAffOneShouldReturnOneArticle() {
     $articles = $this->getArticles(array('display_order' => 'Random',
@@ -385,6 +389,31 @@ class ArticleLoaderGetArticlesByPreferencesTest extends ArticleLoaderGetArticles
     $article = $this->getArticles(array('published' => false));
     $this->assertSelect('WHERE (PARENT_ID=0) ORDER BY `DATE_CREATION` DESC');
   }
+
+
+
+  /** @test */
+  function withArticleSelectionTagPoireShouldAnswersArticleFetePoire() {
+    $articles = $this->getArticles(['display_order' => 'EventDebut',
+                                    'id_items' => '',
+                                    'id_categorie' => '',
+                                    'tag' => 'poire']);
+    $this->assertEquals(['Fête de la poire'],
+                        array_map(function($article) { return $article->getTitre(); },
+                                  $articles));
+  }
+
+
+  /** @test */
+  function withArticleSelectionTagPoMMeShouldAnswersArticleFetePomme() {
+    $articles = $this->getArticles(['display_order' => 'EventDebut',
+                                    'id_items' => '',
+                                    'id_categorie' => '',
+                                    'tag' => 'poMMe']);
+    $this->assertEquals(['Fête de la pomme'],
+                        array_map(function($article) { return $article->getTitre(); },
+                                  $articles));
+  }
 }
 
 
@@ -397,7 +426,7 @@ abstract class ArticleLoaderGroupByBibTestCase extends ModelTestCase {
   protected function setUp() {
     parent::setUp();
 
-    $this->articles = ArticleLoader::groupByBibId($this->_getArticlesFixture());
+    $this->articles = Class_Article_Loader::groupByBibId($this->_getArticlesFixture());
   }
 
 }
diff --git a/tests/library/Class/ArticleTest.php b/tests/library/Class/ArticleTest.php
index 47b9d8fb92508da95bed79bc7ab4ac9269642315..c21326f38635fca5dd0bf40e31ab6465035cb4a3 100644
--- a/tests/library/Class/ArticleTest.php
+++ b/tests/library/Class/ArticleTest.php
@@ -1024,9 +1024,7 @@ abstract class EventsByMonthWithArticleTestCase extends ModelTestCase {
       ->with(['event_date' => '2013-03',
               'events_only' => true,
               'status' => 3])
-      ->answers([$this->concert])
-
-      ->beStrict();
+      ->answers([$this->concert]);
   }
 
 
diff --git a/tests/library/ZendAfi/View/Helper/Accueil/CalendarTest.php b/tests/library/ZendAfi/View/Helper/Accueil/CalendarTest.php
index 7460cd7b0190efad31737e0c93e14536793bbf6a..f3b1e8cdeffce1f8a02f4603ab299346fd6b4e51 100644
--- a/tests/library/ZendAfi/View/Helper/Accueil/CalendarTest.php
+++ b/tests/library/ZendAfi/View/Helper/Accueil/CalendarTest.php
@@ -119,7 +119,7 @@ abstract class CalendarViewTodayTestCase extends CalendarWithEmptyPreferencesTes
   public function setUp() {
     parent::setUp();
 
-    $article_loader = Storm_Test_ObjectWrapper::onLoaderOfModel('Class_Article')
+    $article_loader = Storm_Test_ObjectWrapper::onLoaderOfModel(Class_Article::class)
       ->whenCalled('getArticlesByPreferences')
       ->with([
               'display_order' => 'EventDebut',
@@ -131,7 +131,7 @@ abstract class CalendarViewTodayTestCase extends CalendarWithEmptyPreferencesTes
               'id_lieu' => '',
               'custom_fields' => [],
               'published' => true])
-      ->answers(array($this->nanook2, $this->opac4, $this->amber))
+      ->answers([$this->nanook2, $this->opac4, $this->amber])
 
       ->whenCalled('getArticlesByPreferences')
       ->with([
@@ -143,9 +143,7 @@ abstract class CalendarViewTodayTestCase extends CalendarWithEmptyPreferencesTes
               'id_lieu' => '',
               'custom_fields' => [],
               'published' => true])
-      ->answers(array($this->nanook2, $this->opac4, $this->amber))
-
-      ->beStrict();
+      ->answers([$this->nanook2, $this->opac4, $this->amber]);
   }
 }
 
@@ -269,7 +267,7 @@ class CalendarViewTodayWithFriseChronoTest extends CalendarViewTodayTestCase {
                                  'nb_events' => 1,
                                  'display_calendar' => 2]];
 
-    Storm_Test_ObjectWrapper::onLoaderOfModel('Class_Article')
+    Class_Article::getLoader()
       ->whenCalled('getArticlesByPreferences')
       ->answers([]);
 
@@ -974,15 +972,16 @@ abstract class CalendarHelperDisplayModeTestCase extends CalendarViewHelperTestC
     $time_source = new TimeSourceForTest('2014-07-19 09:00:00');
     ZendAfi_View_Helper_Calendar_Months::setTimeSource($time_source);
 
-    $this->_article_bokeh = $this->fixture('Class_Article',
-                                           [ 'id' => 8,
+    $this->_article_bokeh = $this->fixture(Class_Article::class,
+                                           ['id' => 8,
                                             'titre' => 'Bokeh en prod !',
                                             'events_debut' => '2013-08-21',
                                             'events_fin' => '2013-09-01',
                                             'categorie' => '',
-                                            'contenu' => 'toto'
+                                            'contenu' => 'toto',
+                                            'tags' => 'Geek;bokeh'
                                            ]);
-    $this->onLoaderOfModel('Class_Article')
+    $this->onLoaderOfModel(Class_Article::class)
          ->whenCalled('getArticlesByPreferences')
          ->answers([$this->_article_bokeh]);
 
@@ -1110,17 +1109,19 @@ class CalendarHelperWithWallNavigationModeTest extends CalendarHelperDisplayMode
 
 class CalendarHelperWithFiltersTest extends CalendarHelperDisplayModeTestCase {
   public function setupParams(&$params) {
-    $this->fixture('Class_Lieu', [
-                                  'id' => 1,
-                                  'libelle' => 'Place 1',
-                                  ]);
-
-    $this->fixture('Class_Lieu', [
-                                  'id' => 2,
-                                  'libelle' => 'Place 2',
-                                  ]);
-
-    $theme = $this->fixture('Class_CustomField',
+    $this->fixture(Class_Lieu::class,
+                   [
+                    'id' => 1,
+                    'libelle' => 'Place 1',
+                   ]);
+
+    $this->fixture(Class_Lieu::class,
+                   [
+                    'id' => 2,
+                    'libelle' => 'Place 2',
+                   ]);
+
+    $theme = $this->fixture(Class_CustomField::class,
                             ['id' => 2,
                              'priority' => 3,
                              'label' => 'Theme',
@@ -1128,19 +1129,19 @@ class CalendarHelperWithFiltersTest extends CalendarHelperDisplayModeTestCase {
                              'options_list' => 'music;theater;movie',
                              'model' => 'Article']);
 
-    $this->fixture('Class_CustomField_Value',
+    $this->fixture(Class_CustomField_Value::class,
                    ['id' => 24,
                     'custom_field_id' => $theme->getId(),
                     'model' => $this->_article_bokeh,
                     'value' => 'music']);
 
-    $this->fixture('Class_CustomField_Value',
+    $this->fixture(Class_CustomField_Value::class,
                    ['id' => 25,
                     'custom_field_id' => $theme->getId(),
                     'model' => $this->nanook2,
                     'value' => 'dream theater']);
 
-    $params['preferences']['enabled_filters'] = ';date;place;custom_field_2';
+    $params['preferences']['enabled_filters'] = ';date;place;custom_field_2;tag';
   }
 
 
@@ -1151,8 +1152,8 @@ class CalendarHelperWithFiltersTest extends CalendarHelperDisplayModeTestCase {
 
 
   /** @test */
-  public function filtersShouldContainsThreeUL() {
-    $this->assertXPathCount($this->html, '//ul[contains(@class, "filters")]//ul', 3, $this->html);
+  public function filtersShouldContainsFourUL() {
+    $this->assertXPathCount($this->html, '//ul[contains(@class, "filters")]//ul', 4, $this->html);
   }
 
 
@@ -1211,6 +1212,17 @@ class CalendarHelperWithFiltersTest extends CalendarHelperDisplayModeTestCase {
                                       '//ul[contains(@class, "filters")]/li[@class="custom_field_2"]//li[3]/a[contains(@href, "custom_field_2/music")]', 'music');
 
   }
+
+
+  /** @test */
+  public function tagFilterShouldContainsGeekAndBokeh() {
+    $this->assertXPath($this->html,
+                       '//ul[contains(@class, "filters")]/li[@class="tag"]'
+                       . '//li[child::a[contains(@href, "tag/bokeh")][text()="bokeh"]]'
+                       . '/following-sibling::li/a[contains(@href, "tag/geek")][text()="geek"]',
+                       $this->html);
+
+  }
 }
 
 
@@ -1288,4 +1300,4 @@ class CalendarHelperWithPreviousMonthEventsTest extends ViewHelperTestCase {
                                       '//a[contains(@href, "2015-10-29")][contains(@class, "calendar_other_month")]',
                                       '29');
   }
-}
\ No newline at end of file
+}
diff --git a/tests/library/ZendAfi/View/Helper/TagArticleEventTest.php b/tests/library/ZendAfi/View/Helper/TagArticleEventTest.php
index 3ec772945226e2ead6192a1a2d5ae8fa9bb1cf63..4685fcd494242a15840f3876b0b2b5b6eddc57d0 100644
--- a/tests/library/ZendAfi/View/Helper/TagArticleEventTest.php
+++ b/tests/library/ZendAfi/View/Helper/TagArticleEventTest.php
@@ -94,6 +94,15 @@ class TagArticleEventTest extends ViewHelperTestCase {
   }
 
 
+  /** @test */
+  function withEventDebutAndNoFinShouldAnswerLundi05Septembre2011AtEight() {
+    $this->article
+      ->setEventsDebut('2011-09-05 08:00')
+      ->setEventsFin('');
+    $this->assertTagContains('lundi 05 septembre 2011 à 08:00');
+  }
+
+
   /** @test */
   function withAllDayAndEventDebutAndFinSameDayShouldAnswerLe05() {
     $this->article
@@ -135,7 +144,7 @@ class TagArticleEventTest extends ViewHelperTestCase {
 
 
 
-    /** @test */
+  /** @test */
   public function withMondayThuesdayThursdayAndSaturdayPickShouldAnswersTousLesLundisMardisMercredisEtTousLesSamedi() {
     $this->article
       ->setEventsDebut('2011-09-05 08:00')
diff --git a/tests/scenarios/ArticlesMultipleTimings/ArticlesMultipleTimingsAdminTest.php b/tests/scenarios/ArticlesMultipleTimings/ArticlesMultipleTimingsAdminTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..30844c9458bcd741a6c9473e3a2d8deddc22d821
--- /dev/null
+++ b/tests/scenarios/ArticlesMultipleTimings/ArticlesMultipleTimingsAdminTest.php
@@ -0,0 +1,530 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ArticlesMultipleTimingsAdminVarTest extends Admin_AbstractControllerTestCase {
+  protected $_storm_default_to_volatile = true;
+
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/admin/index/adminvar');
+  }
+
+
+  /** @test */
+  public function pageShouldContainsLinkToAdminVarENABLE_ARTICLES_TIMINGS() {
+    $this->assertXPathContentContains('//a/@href',
+                                      '/admin/index/adminvaredit/cle/ENABLE_ARTICLES_TIMINGS');
+  }
+}
+
+
+
+
+class ArticlesMultipleTimingsAdminActivationCmsControllerTest extends Admin_AbstractControllerTestCase {
+  protected $_storm_default_to_volatile = true;
+
+
+  public function setUp() {
+    parent::setUp();
+    $this->fixture(Class_Article::class,
+                   ['id' => 2,
+                    'titre' => 'Heure du conte',
+                    'contenu' => 'Tous les mercredis']);
+
+  }
+
+
+  /** @test */
+  public function with_ENABLE_ARTICLES_TIMINGS_setDivHeaderActionsShouldContainsLinkToEventTimings() {
+    Class_AdminVar::set('ENABLE_ARTICLES_TIMINGS', 1);
+    $this->dispatch('/admin/cms/edit/id/2');
+    $this->assertXPath('//div[@class="header_actions"]//a[contains(@href,"/admin/cms/event-timings/article_id/2")]/img[@title="Horaires de l\'événement : Heure du conte"]');
+  }
+
+
+
+  /** @test */
+  public function without_ENABLE_ARTICLES_TIMINGS_setDivHeaderActionsShouldNotContainsLinkToEventTimings() {
+    Class_AdminVar::set('ENABLE_ARTICLES_TIMINGS', 0);
+    $this->dispatch('/admin/cms/edit/id/2');
+    $this->assertNotXPathContentContains('//div[@class="header_actions"]//a/@href',
+                                         '/admin/cms/event-timings');
+  }
+}
+
+
+
+
+abstract class ArticlesMultipleTimingsAdminTestCase extends Admin_AbstractControllerTestCase {
+  protected $_storm_default_to_volatile = true;
+
+
+  public function setUp() {
+    parent::setUp();
+    $this->fixture(Class_Article::class,
+                   ['id' => 2,
+                    'titre' => 'Heure du conte',
+                    'contenu' => 'Tous les mercredis'])
+         ->addEventTiming($this->fixture(Class_Article_EventTiming::class,
+                                         ['id' => 1,
+                                          'start' => '2021-07-07 10:00:00',
+                                          'end' => '2021-07-07 11:00:00']))
+         ->addEventTiming($this->fixture(Class_Article_EventTiming::class,
+                                         ['id' => 2,
+                                          'start' => '2021-07-14 10:00:00',
+                                          'end' => '2021-07-14 11:00:00']));
+
+
+    Class_AdminVar::set('ENABLE_ARTICLES_TIMINGS', 1);
+  }
+}
+
+
+
+
+
+class ArticlesMultipleTimingsAdminCmsControllerEventTimingIndexArticleNotFoundTest extends ArticlesMultipleTimingsAdminTestCase {
+
+
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/admin/cms/event-timings/article_id/666');
+  }
+
+  /** @test */
+  public function responseShouldRedirectToIndex() {
+    $this->assertRedirectTo('/admin/cms');
+  }
+}
+
+
+
+
+class ArticlesMultipleTimingsAdminCmsControllerEventTimingIndexTest extends ArticlesMultipleTimingsAdminTestCase {
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/admin/cms/event-timings/article_id/2');
+  }
+
+
+  /** @test */
+  public function pageTitleShouldBeHorairesPourLHeureDuConte() {
+    $this->assertXPathContentContains('//h1', 'Horaires de l\'événement : Heure du conte');
+  }
+
+
+  /** @test */
+  public function pageShouldContainsButtonToEditAction() {
+    $this->assertXPath('//button[contains(@data-url, "/admin/cms/edit/id/2")]',
+                       'Retour à l\'article : Heure du conte');
+  }
+
+
+  /** @test */
+  public function pageShouldContainsButtonToAddAction() {
+    $this->assertXPath('//button[@data-popup="true"][contains(@data-url, "/admin/cms/event-timing-add/article_id/2")]',
+                       'Ajouter un horaire pour : Heure du conte');
+  }
+
+
+  /** @test */
+  public function pageShouldContainsTableWithRowForFirstEventTiming() {
+    $this->assertXPath('//tr/td[text()="2021-07-07 10:00"]/following-sibling::td[text()="2021-07-07 11:00"]');
+  }
+
+
+  /** @test */
+  public function pageShouldContainsTableWithRowForSecondEventTiming() {
+    $this->assertXPath('//tr/td[text()="2021-07-14 10:00"]/following-sibling::td[text()="2021-07-14 11:00"]');
+  }
+
+
+  /** @test */
+  public function tableShouldContainsActionToEditFirstEventTiming() {
+    $this->assertXPathContentContains('//tr/td/a[@data-popup="true"]/@href',
+                                      '/admin/cms/event-timing-edit/id/1');
+  }
+
+
+  /** @test */
+  public function tableShouldContainsActionToEditSecondEventTiming() {
+    $this->assertXPathContentContains('//tr/td/a[@data-popup="true"]/@href',
+                                      '/admin/cms/event-timing-edit/id/2');
+  }
+
+
+  /** @test */
+  public function tableShouldContainsActionToDeleteFirstEventTiming() {
+    $this->assertXPathContentContains('//tr/td/a/@href',
+                                      '/admin/cms/event-timing-delete/id/1');
+  }
+
+
+  /** @test */
+  public function onDeleteShouldAskForConfirmation() {
+    $this->assertXPath('//tr/td/a[contains(@href,"delete")][contains(@onclick,"confirm")]');
+  }
+}
+
+
+
+
+class ArticlesMultipleTimingsAdminDeleteTest extends ArticlesMultipleTimingsAdminTestCase {
+
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/admin/cms/event-timing-delete/id/1');
+  }
+
+
+  /** @test */
+  public function eventTimingOneShouldNotExist() {
+    $this->assertNull(Class_Article_EventTiming::find(1));
+  }
+
+
+  /** @test */
+  public function afterDeleteShouldRedirectToEventTimingForArticle2() {
+    $this->assertRedirect('/admin/cms/event-timings/article_id/2');
+  }
+
+
+  /** @test */
+  public function deleteShouldTriggerDeleteMessage() {
+    $this->assertFlashMessengerContentContains('L\'horaire a été supprimé');
+  }
+}
+
+
+
+
+class ArticlesMultipleTimingsAdminCmsDeleteArticleTest extends ArticlesMultipleTimingsAdminTestCase {
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/admin/cms/force-delete/id/2');
+  }
+
+
+  /** @test */
+  public function eventTimingsShouldBeCascadeDeleted() {
+    $this->assertEmpty(Class_Article_EventTiming::findAll());
+  }
+
+
+    /** @test */
+  public function articleTwoShouldBeDeleted() {
+    $this->assertEmpty(Class_Article::find(2));
+  }
+}
+
+
+
+
+
+class ArticlesMultipleTimingsAdminEditTest extends ArticlesMultipleTimingsAdminTestCase {
+
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/admin/cms/event-timing-edit/id/1');
+  }
+
+
+/** @test */
+  public function editFormShouldHaveTitleHeureDuConteEditerUnHoraire() {
+    $this->assertXPathContentContains('//head//title','Heure du conte : modifier un horaire');
+  }
+
+
+  /** @test */
+  public function editFormShouldContainStartDateInput() {
+    $this->assertXPath('//input[@id="start"][@value="07/07/2021 10:00"]');
+  }
+
+
+  /** @test */
+  public function editFormShouldContainEndDateInput() {
+    $this->assertXPath('//input[@id="end"][@value="07/07/2021 11:00"]');
+  }
+
+
+  /** @test */
+  public function editFormShouldContainsActionToEventTimingEdit() {
+    $this->assertXPath('//form[contains(@action,"event-timing-edit")]',$this->_response->getBody());
+  }
+}
+
+
+
+
+class ArticlesMultipleTimingsAdminEditWithWrongIdTest extends ArticlesMultipleTimingsAdminTestCase {
+  public function setUp() {
+    parent::setup();
+    $this->dispatch('/admin/cms/event-timing-edit/id/42');
+  }
+
+
+  /** @test */
+  public function withNonExistingEventTimingShouldRedirectToCmsIndex() {
+    $this->assertRedirectTo('/admin/cms');
+  }
+}
+
+
+
+
+class ArticlesMultipleTimingsAdminEditWithParamsTest extends ArticlesMultipleTimingsAdminTestCase {
+
+  protected $_event_time;
+
+  public function setUp() {
+    parent::setup();
+    $this->postDispatch('/admin/cms/event-timing-edit/id/1',
+                        ['start' => '07/07/2021 11:00',
+                         'end' => '07/07/2021 12:00']);
+
+    $this->_event_time = Class_Article_EventTiming::find(1);
+  }
+
+
+  /** @test */
+  public function eventTimingOneShouldStart2021_07_07_11H00() {
+    $this->assertEquals('2021-07-07 11:00', $this->_event_time->getStart());
+  }
+
+
+  /** @test */
+  public function eventTimingOneShouldEnd2021_07_07_12H00() {
+    $this->assertEquals('2021-07-07 12:00', $this->_event_time->getEnd());
+  }
+
+
+  /** @test */
+  public function onSaveShouldNotifyMisAJour() {
+    $this->assertFlashMessengerContentContains('Les horaires ont été mis à jour');
+  }
+
+
+  /** @test */
+  public function articleShouldStillBeHeureDuConte() {
+    $this->assertEquals('Heure du conte', $this->_event_time->getLibelle());
+  }
+
+
+  /** @test */
+  public function onSaveShouldRedirectToAdminCmsEventTimingEditIdOne() {
+    $this->assertRedirectTo(Class_Url::absolute('/admin/cms/event-timing-edit/id/1/article_id/2'));
+  }
+}
+
+
+
+
+class ArticlesMultipleTimingsAdminAddDispatchTest
+  extends ArticlesMultipleTimingsAdminTestCase {
+
+  public function setUp() {
+    parent::setup();
+    $this->dispatch('/admin/cms/event-timing-add/article_id/2');
+  }
+
+
+  /** @test */
+  public function pageTitleShouldBeAjouterUnHoraire() {
+    $this->assertXPathContentContains('//head//title', 'Ajouter un horaire');
+  }
+
+
+  /** @test */
+  public function pageInputLabelShouldContainsDateEtHeureDebut() {
+    $this->assertXPathContentContains('//form//label[@for="start"]', 'Date et heure de début');
+  }
+
+
+  /** @test */
+  public function pageInputLabelShouldContainsDateEtHeureFin() {
+    $this->assertXPathContentContains('//form//label[@for="end"]', 'Date et heure de fin');
+  }
+}
+
+
+
+
+
+class ArticlesMultipleTimingsAdminAddPostDispatchTest extends ArticlesMultipleTimingsAdminTestCase {
+  protected $_event_time;
+
+  public function setUp() {
+    parent::setup();
+    $this->postDispatch('/admin/cms/event-timing-add/article_id/2',
+                        ['start' => '21/07/2021 10:00',
+                         'end' => '21/07/2021 11:00']);
+    $this->_event_time = Class_Article_EventTiming::find(3);
+  }
+
+
+
+  /** @test */
+  public function eventTimingThreeShouldStart2021_07_21_10H00() {
+    $this->assertEquals('2021-07-21 10:00', $this->_event_time->getStart());
+  }
+
+
+  /** @test */
+  public function eventTimingThreeShouldEnd2021_07_21_11H00() {
+    $this->assertEquals('2021-07-21 11:00', $this->_event_time->getEnd());
+  }
+
+
+  /** @test */
+  public function eventTimingThreeShouldBelongToArticle2() {
+    $this->assertEquals(Class_Article::find(2), $this->_event_time->getArticle());
+  }
+
+
+  /** @test */
+  public function onSaveShouldNotifyNouvelHoraireCrée() {
+    $this->assertFlashMessengerContentContains('Un nouvel horaire a été créé');
+  }
+
+
+  /** @test */
+  public function onSaveShouldRedirectToAdminCmsEventTimingArticle2Id3() {
+    $this->assertRedirectTo(Class_Url::absolute('/admin/cms/event-timing-edit/article_id/2/id/3'));
+  }
+}
+
+
+
+
+
+class ArticlesMultipleTimingsAdminAddPostDispatchWithEndBeforeStartTest extends ArticlesMultipleTimingsAdminTestCase {
+
+  public function setUp() {
+    parent::setup();
+    $this->postDispatch('/admin/cms/event-timing-add/article_id/2',
+                        ['start' => '22/07/2021 10:00',
+                         'end' => '21/07/2021 11:00']);
+  }
+
+
+  /** @test */
+  public function contextShouldExpectation() {
+    $this->assertXPathContentContains('//ul[@class="errors"]/li','La date de début doit être antérieure à la date de fin',$this->_response->getBody());
+  }
+}
+
+
+
+
+class ArticlesMultipleTimingsAdminAddPostDispatchWithSingleDateTest extends ArticlesMultipleTimingsAdminTestCase {
+
+  public function setUp() {
+    parent::setup();
+    $this->postDispatch('/admin/cms/event-timing-add/article_id/2',
+                        ['start' => '22/07/2021 10:00']);
+  }
+
+
+  /** @test */
+  public function contextShouldExpectation() {
+    $this->assertXPathContentContains('//ul[@class="errors"]/li','Une valeur est requise');
+  }
+}
+
+
+
+
+class ArticlesEventTimingAddPostDispatchWithWrongFormatDateTest extends ArticlesMultipleTimingsAdminTestCase {
+
+  public function setUp() {
+    parent::setup();
+    $this->postDispatch('/admin/cms/event-timing-add/article_id/2',
+                        ['start' => 'test']);
+  }
+
+
+  /** @test */
+  public function withInvalidDateShouldContainError() {
+    $this->assertXPathContentContains('//ul[@class="errors"]/li',"'test' n'est pas une date au format attendu");
+  }
+}
+
+
+
+
+class ArticlesMultipleTimingsAdminCmsEditEventTimingAsRedacteurTest extends ArticlesMultipleTimingsAdminTestCase {
+
+  public function setUp() {
+    parent::setUp();
+    $this->fixture(Class_Bib::class,
+                   ['id' => 1,
+                    'libelle' => 'Spirou' ]);
+
+    $this->fixture(Class_Bib::class,
+                   ['id' => 2,
+                    'libelle' => 'Fantasio']);
+
+    $article_categorie = $this->fixture(Class_ArticleCategorie::class,
+                                        ['id' => 1,
+                                         'libelle' => 'Root',
+                                         'id_site' => 1]);
+
+    Class_Article::find(2)
+      ->setCategorie($article_categorie)
+      ->assertSave();
+
+    $redacteur_group = $this->fixture(Class_UserGroup::class,
+                                      ['id' => 1,
+                                       'libelle' => 'Allowed Group']);
+
+    $redacteur = $this->fixture(Class_Users::class,
+                                ['id' => 5,
+                                 'login' => 'redac',
+                                 'password' => 'chef',
+                                 'role_level' => ZendAfi_Acl_AdminControllerRoles::MODO_BIB,
+                                 'user_groups' => [$redacteur_group],
+                                 'id_site' => 1]);
+
+    $this->fixture(Class_Permission::class,
+                     ['id' => 666,
+                      'code' => 'ARTICLE',
+                      'module' => 'ARTICLE']);
+
+    Class_Permission::createArticle()->permitTo($redacteur_group, $article_categorie);
+    ZendAfi_Auth::getInstance()->logUser($redacteur);
+  }
+
+
+  /** @test */
+  public function withUserAllowedToEditArticleShouldDisplayForm() {
+    $this->dispatch('/admin/cms/event-timing-edit/id/1');
+    $this->assertXPath('//form[contains(@action,"event-timing-edit")]');
+  }
+
+
+  /** @test */
+  public function withUserForbiddenShouldRedirect() {
+    Class_Users::find(5)->setIdSite(2)->assertSave();
+    $this->dispatch('/admin/cms/event-timing-edit/id/1');
+    $this->assertRedirectTo('/admin/cms/event-timings/article_id/2');
+  }
+}
\ No newline at end of file
diff --git a/tests/scenarios/ArticlesMultipleTimings/ArticlesMultipleTimingsCalendarTest.php b/tests/scenarios/ArticlesMultipleTimings/ArticlesMultipleTimingsCalendarTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..54f2d6ba7f37e1b0f5f15e05f36a9600cb45e03d
--- /dev/null
+++ b/tests/scenarios/ArticlesMultipleTimings/ArticlesMultipleTimingsCalendarTest.php
@@ -0,0 +1,166 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ArticlesMultipleTimingsCalendarTest extends ViewHelperTestCase {
+protected $_storm_default_to_volatile = true;
+
+
+  public function setUp() {
+    parent::setUp();
+
+    $apero_timing_one = $this->fixture(Class_Article_EventTiming::class,
+                                       ['id' => 1,
+                                        'start' => '2021-06-01 10:00:00',
+                                        'end' => '2021-06-01 11:00:00']);
+
+
+    $apero_timing_two = $this->fixture(Class_Article_EventTiming::class,
+                                       ['id' => 2,
+                                        'start' => '2021-06-10 10:00:00',
+                                        'end' => '2021-06-10 11:00:00']);
+
+    /**
+     * See Class_Article_Loader::newFromRow
+     *
+     * For improved performances, loading of articles is joined with
+     * timings table.
+     * As there's a group by (see Class_Article_SelectWithTimings) and
+     * that all code use events_debut / event_fin, with timings start is moved to events_debut
+     * and end to events_fin
+     *
+     * But Calendar may fetch several times same Article and finally merge all results
+     * (see Calendar::_getNextEvents). array_merge fails as the same Article fetched
+     * twice may have different events_debut / events_fin.
+     *
+     * These fixtures reproduce the problem
+     */
+    $apero_first_fetch = Class_Article::newInstance(['id' => 1,
+                                                     'titre' => 'Apéro !',
+                                                     'contenu' => 'En toute modération',
+                                                     'events_debut' => $apero_timing_one->getStart(),
+                                                     'events_fin' => $apero_timing_one->getEnd(),
+                                                     'event_timings' => [$apero_timing_one,
+                                                                         $apero_timing_two]]);
+
+    $apero_second_fetch = Class_Article::newInstance(['id' => 1,
+                                                      'titre' => 'Apéro !',
+                                                      'contenu' => 'En toute modération',
+                                                      'events_debut' => $apero_timing_two->getStart(),
+                                                      'events_fin' => $apero_timing_two->getEnd(),
+                                                      'event_timings' => [$apero_timing_one,
+                                                                          $apero_timing_two]]);
+
+
+    $this->onLoaderOfModel(Class_Article::class)
+         ->whenCalled('getArticlesByPreferences')
+         ->with([
+                 'display_order' => 'EventDebut',
+                 'id_categorie' => '',
+                 'events_only' => true,
+                 'event_date' => '2021-06',
+                 'id_bib' => 0,
+                 'id_lieu' => '',
+                 'custom_fields' => [],
+                 'published' => true,
+                 'event_end_after' => '2021-06-01'])
+         ->answers([$apero_first_fetch])
+
+         ->whenCalled('getArticlesByPreferences')
+         ->with([
+                 'display_order' => 'EventDebut',
+                 'id_categorie' => '',
+                 'events_only' => true,
+                 'event_date' => '',
+                 'id_bib' => 0,
+                 'id_lieu' => '',
+                 'custom_fields' => [],
+                 'published' => true,
+                 'event_start_after' => '2021-06',
+                 'event_end_after' => '',
+                 'limit' => 3])
+         ->answers([$apero_second_fetch,
+                    Class_Article::newInstance(['id' => 2,
+                                                'titre' => 'Dessert',
+                                                'events_debut' => '2021-06-12 09:00'])
+                    ])
+
+         ->whenCalled('getArticlesByPreferences')
+         ->with([
+                 'display_order' => 'EventDebut',
+                 'id_categorie' => '',
+                 'events_only' => true,
+                 'event_date' => '2021-06',
+                 'id_bib' => 0,
+                 'id_lieu' => '',
+                 'custom_fields' => [],
+                 'published' => true])
+         ->answers([])
+
+         ->beStrict();
+
+
+    ZendAfi_View_Helper_CalendarContent::setTimeSource(new TimeSourceForTest('2021-06-01 09:00:00'));
+
+    $params = ['division' => '2',
+               'type_module' => 'CALENDAR',
+               'preferences' => ['titre' => 'Agenda']];
+    $helper = new ZendAfi_View_Helper_Accueil_Calendar(2, $params);
+    $helper->setView(new ZendAfi_Controller_Action_Helper_View());
+    $this->html = $helper->getBoite();
+  }
+
+
+  /** @test */
+  public function calendarShouldContainsOnlyOneArticleApero() {
+    $this
+      ->assertXPathCount($this->html,
+                         '//a[@class="calendar_event_title"][contains(@href, "cms/articleview/id/1")]',
+                         1);
+  }
+
+
+  /** @test */
+  public function calendarShouldContainsArticleWithFirstEventsDebut2021_06_01() {
+    $this
+      ->assertXPathContentContains($this->html,
+                                   '//span[@class="calendar_event_date"]',
+                                   'mardi 01 juin 2021');
+
+  }
+
+
+  /** @test */
+  public function calendarShouldContainsTwoArticles() {
+    $this
+      ->assertXPathCount($this->html,
+                         '//a[@class="calendar_event_title"]',
+                         2);
+  }
+
+
+  /** @test */
+  public function calendarSecondArticleShouldBeDessert() {
+      $this
+      ->assertXPath($this->html,
+                    '//a[@class="calendar_event_title"][contains(@href, "cms/articleview/id/2")]');
+  }
+}
diff --git a/tests/scenarios/ArticlesMultipleTimings/ArticlesMultipleTimingsLoaderTest.php b/tests/scenarios/ArticlesMultipleTimings/ArticlesMultipleTimingsLoaderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..884846da7f9801083a3142be1fa4a62e98ac235c
--- /dev/null
+++ b/tests/scenarios/ArticlesMultipleTimings/ArticlesMultipleTimingsLoaderTest.php
@@ -0,0 +1,146 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+require_once(__DIR__ . '/../../library/Class/ArticleLoaderTest.php');
+
+class ArticlesMultipleTimingsLoaderTest extends ArticleLoaderGetArticlesByPreferencesTestCase {
+  public function setUp() {
+    parent::setUp();
+    Class_Article_SelectWithTimings::setTimeSource(new TimeSourceForTest('2011-10-20 10:00:00'));
+    Class_AdminVar::set('ENABLE_ARTICLES_TIMINGS', 1);
+  }
+
+
+  public function tearDown() {
+    Class_Article_SelectWithTimings::setTimeSource(null);
+    parent::tearDown();
+  }
+
+
+  protected function _articlesFixtures() {
+    return [
+            [
+             'ID_ARTICLE' => 23,
+             'ID_CAT' => 2,
+             'TITRE' => 'Fête de la pomme',
+             'DATE_CREATION' => '2011-04-02',
+             'DEBUT' => '2011-10-02',
+             'FIN' => '2011-10-22',
+             'EVENTS_DEBUT' => '',
+             'EVENTS_FIN' => '',
+             'START' => '2011-10-20',
+             'END' => '2011-10-21']
+    ];
+  }
+
+
+  public function assertSelect($expected) {
+    $this->assertEquals("SELECT `cms_article`.*, `cms_article_timings`.`start`, `cms_article_timings`.`end` FROM `cms_article` ".$expected,
+                        str_replace("\n", "", $this->select->assemble()));
+  }
+
+
+  /** @test */
+  public function withEventsOnlyShouldFilterOnEventsDates() {
+    $article = $this->getArticles(['events_only' => true]);
+    $this->assertSelect(sprintf('LEFT JOIN `cms_article_timings` ON cms_article.id_article=cms_article_timings.article_id WHERE %s AND (EVENTS_DEBUT IS NOT NULL OR START IS NOT NULL) AND (EVENTS_FIN IS NOT NULL OR (END IS NOT NULL AND END >= CURDATE())) AND (PARENT_ID=0) GROUP BY `id_article` ORDER BY `DATE_CREATION` DESC', self::WHERE_VISIBLE_CLAUSE));
+  }
+
+
+  /** @test */
+  public function withDayDateSqlShouldFilterEventsByDay() {
+    $articles = $this->getArticles(['display_order' => 'EventDebut',
+                                    'id_items' => '',
+                                    'id_categorie' => '',
+                                    'event_date' => '2011-03-15',
+                                    'id_bib' => 0]);
+    $this->assertSelect(sprintf("LEFT JOIN `cms_article_timings` ON cms_article.id_article=cms_article_timings.article_id WHERE %s AND (EVENTS_DEBUT IS NOT NULL OR START IS NOT NULL) AND (EVENTS_FIN IS NOT NULL OR END IS NOT NULL) AND (left(EVENTS_DEBUT,10) <= '2011-03-15' OR left(START,10) <= '2011-03-15') AND (left(EVENTS_FIN,10) >= '2011-03-15' OR left(END,10) >= '2011-03-15') AND (PARENT_ID=0) GROUP BY `id_article` ORDER BY `DATE_CREATION` DESC",
+                                self::WHERE_VISIBLE_CLAUSE));
+  }
+
+
+  /** @test */
+  public function withEventEndAfterSqlShouldFilterOnEventsFin() {
+    $articles = $this->getArticles(['event_end_after' => '2011-03-15']);
+    $this->assertSelect(sprintf("LEFT JOIN `cms_article_timings` ON cms_article.id_article=cms_article_timings.article_id WHERE %s AND (EVENTS_FIN IS NOT NULL OR END IS NOT NULL) AND (left(EVENTS_FIN,10) >= '2011-03-15' OR left(END,10) >= '2011-03-15') AND (PARENT_ID=0) GROUP BY `id_article` ORDER BY `DATE_CREATION` DESC",  self::WHERE_VISIBLE_CLAUSE));
+  }
+
+
+  /** @test */
+  public function withMonthDateSqlShouldFilterEventsByMonth() {
+    $articles = $this->getArticles(['display_order' => 'EventDebut',
+                                    'id_items' => '',
+                                    'id_categorie' => '',
+                                    'event_date' => '2011-03',
+                                    'id_bib' => 0]);
+    $this->assertSelect(sprintf("LEFT JOIN `cms_article_timings` ON cms_article.id_article=cms_article_timings.article_id WHERE %s AND (EVENTS_DEBUT IS NOT NULL OR START IS NOT NULL) AND (EVENTS_FIN IS NOT NULL OR END IS NOT NULL) AND (left(EVENTS_DEBUT,7) <= '2011-03' OR left(START,7) <= '2011-03') AND (left(EVENTS_FIN,7) >= '2011-03' OR left(END,7) >= '2011-03') AND (PARENT_ID=0) GROUP BY `id_article` ORDER BY `DATE_CREATION` DESC",
+                                self::WHERE_VISIBLE_CLAUSE));
+    return $articles;
+  }
+
+
+  /**
+   * @depends withMonthDateSqlShouldFilterEventsByMonth
+   * @test
+   **/
+  public function articleEventsDebutAndFinShouldBeReplacedByStartEnd($articles) {
+    $this->assertEquals(['2011-10-20', '2011-10-21'],
+                        [$articles[0]->getEventsDebut(), $articles[0]->getEventsFin()]);
+  }
+
+
+
+  /** @test */
+  public function withMonthDateAndEventsOnlySqlShouldFilterEventsByMonth() {
+    $articles = $this->getArticles(['display_order' => 'EventDebut',
+                                    'id_items' => '',
+                                    'id_categorie' => '',
+                                    'events_only' => true,
+                                    'event_date' => '2011-03',
+                                    'id_bib' => 0]);
+    $this->assertSelect(sprintf("LEFT JOIN `cms_article_timings` ON cms_article.id_article=cms_article_timings.article_id WHERE %s AND (EVENTS_DEBUT IS NOT NULL OR START IS NOT NULL) AND (EVENTS_FIN IS NOT NULL OR END IS NOT NULL) AND (left(EVENTS_DEBUT,7) <= '2011-03' OR left(START,7) <= '2011-03') AND (left(EVENTS_FIN,7) >= '2011-03' OR left(END,7) >= '2011-03') AND (PARENT_ID=0) GROUP BY `id_article` ORDER BY `DATE_CREATION` DESC",
+                                self::WHERE_VISIBLE_CLAUSE));
+  }
+
+
+  /** @test */
+  public function withMonthDateOnCurrentMonthSqlShouldSelectEventsAfterCurdate() {
+    Class_Article_SelectWithTimings::setTimeSource(new TimeSourceForTest('2011-03-20 10:00:00'));
+    $articles = $this->getArticles(['display_order' => 'EventDebut',
+                                    'id_items' => '',
+                                    'id_categorie' => '',
+                                    'events_only' => true,
+                                    'event_date' => '2011-03',
+                                    'id_bib' => 0]);
+    $this->assertSelect(sprintf("LEFT JOIN `cms_article_timings` ON cms_article.id_article=cms_article_timings.article_id WHERE %s AND (EVENTS_DEBUT IS NOT NULL OR START IS NOT NULL) AND (EVENTS_FIN IS NOT NULL OR END IS NOT NULL) AND (left(EVENTS_DEBUT,7) <= '2011-03' OR left(START,7) <= '2011-03') AND (left(EVENTS_FIN,7) >= '2011-03' OR (left(END,7) >= '2011-03' AND END >= CURDATE())) AND (PARENT_ID=0) GROUP BY `id_article` ORDER BY `DATE_CREATION` DESC",
+                                self::WHERE_VISIBLE_CLAUSE));
+  }
+
+
+  /** @test */
+  public function withInvalidEventDateShouldNotRestrictOnEventDate() {
+    $articles = $this->getArticles(['event_date' => 'trololo',
+                                    'event_start_after' => 'test',
+                                    'event_end_after' => 'raté']);
+    $this->assertSelect(sprintf("LEFT JOIN `cms_article_timings` ON cms_article.id_article=cms_article_timings.article_id WHERE %s AND (PARENT_ID=0) GROUP BY `id_article` ORDER BY `DATE_CREATION` DESC",
+                                self::WHERE_VISIBLE_CLAUSE));
+  }
+}
diff --git a/tests/scenarios/ArticlesMultipleTimings/ArticlesMultipleTimingsOpenAgendaImportTest.php b/tests/scenarios/ArticlesMultipleTimings/ArticlesMultipleTimingsOpenAgendaImportTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..9263b16681a3b32566f0ba70c672d472aab12b92
--- /dev/null
+++ b/tests/scenarios/ArticlesMultipleTimings/ArticlesMultipleTimingsOpenAgendaImportTest.php
@@ -0,0 +1,123 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+require_once(__DIR__ . '/../ExternalAgendas/ExternalAgendasOpenAgendaTest.php');
+
+
+class ArticlesMultipleTimingsOpenAgendaImportTest extends ExternalAgendasOpenAgendaImportTestCase {
+
+  public function setUp() {
+    $this->fixture(Class_Article::class,
+                   ['id' => 2,
+                    'id_origine' => '84528178',
+                    'titre' => 'Risette',
+                    'contenu' => 'le contenu et les horaires doivent être réécrits',
+                   ]);
+
+    $this->fixture(Class_Article_EventTiming::class,
+                   ['id' => 1,
+                    'id_article' => 2,
+                    'start' => '2021-07-08 10:00',
+                    'end' => '2021-07-08 11:00'
+                   ]);
+
+    parent::setUp();
+    Class_AdminVar::set('ENABLE_ARTICLES_TIMINGS', 1);
+    Class_ExternalAgenda::find(12)->import();
+  }
+
+
+  /** @test */
+  public function countArticlesShouldBeFour() {
+    $this->assertCount(4, Class_Article::findAll());
+  }
+
+
+  /** @test */
+  public function firstArticleIdOrigineShouldBe5519006() {
+    $this->assertEquals('5519006',
+                        Class_Article::find(1)->getIdOrigine());
+  }
+
+
+
+  /** @test */
+  public function firstArticleUpdatedAtShouldBe20191126() {
+    $this->assertEquals('2019-11-26T14:12:06.000Z', Class_Article::find(1)->getDateMaj());
+  }
+
+
+    /** @test */
+  public function firstArticleImageShouldContainsHTMLAndImage() {
+    $this->assertEquals('<figure><img src="https://cibul.s3.amazonaws.com/9c3729cce33140c5a011056c8168ec5b.base.image.jpg" alt=""/><figcaption>Credits : moi</figcaption></figure><p>Voyons ça dans une session de coding dojo</p><p>Infos pratiques :</p><dl><dt>Conditions</dt><dd>être geek</dd><dt>Âge</dt><dd>de 6 à 99 ans</dd></dl><p>Pour s\'inscrire :</p><dl><dt>Courriel</dt><dd><a href="mailto:llaffont@afi-sa.fr">llaffont@afi-sa.fr</a></dd><dt>Téléphone</dt><dd><a href="tel:0123456789">0123456789</a></dd><dt>Site</dt><dd><a href="https://www.website.org">https://www.website.org</a></dd><dt>Lien</dt><dd><a href="https://registration.website.org/">https://registration.website.org/</a></dd></dl>',
+                        Class_Article::find(1)->getContenu());
+  }
+
+
+  /** @test */
+  public function firstArticleShouldHaveThreeTimings() {
+    $timings = array_map(function($timing) { return $timing->getRawAttributes(); },
+                         Class_Article::find(1)->getEventTimings());
+    $this->assertEquals([
+                         ['start' => '2019-11-25 09:30',
+                          'end' => '2019-11-25 11:30',
+                          'article_id' => 1,
+                          'id' => 1],
+
+                         ['start' => '2019-11-29 09:00',
+                          'end' => '2019-11-29 11:00',
+                          'article_id' => 1,
+                          'id' => 2],
+
+                         ['start' => '2019-12-01 09:30',
+                          'end' => '2019-12-01 10:30',
+                          'article_id' => 1,
+                          'id' => 3]],
+
+                        $timings);
+
+  }
+
+
+  /** @test */
+  public function articleTwoShouldHaveIdOrigine84528178() {
+    $this->assertEquals('84528178', Class_Article::find(2)->getIdOrigine());
+  }
+
+
+  /** @test */
+  public function articleTwoShouldHaveTimingsSameAsOrigine() {
+    $timings = array_map(function($timing) { return $timing->getRawAttributes(); },
+                         Class_Article::find(2)->getEventTimings());
+    $this->assertEquals([
+                         ['start' => '2019-11-25 10:00',
+                          'end' => '2019-11-25 10:30',
+                          'article_id' => 2,
+                          'id' => 4],
+                         ['start' => '2019-12-02 10:00',
+                          'end' => '2019-12-02 10:30',
+                          'article_id' => 2,
+                          'id' => 5]
+                         ],
+                        $timings);
+
+  }
+}
diff --git a/tests/scenarios/ArticlesMultipleTimings/ArticlesMultipleTimingsViewTest.php b/tests/scenarios/ArticlesMultipleTimings/ArticlesMultipleTimingsViewTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..d729d5c85fa1a53bf30243b1c8394c43698fbfb5
--- /dev/null
+++ b/tests/scenarios/ArticlesMultipleTimings/ArticlesMultipleTimingsViewTest.php
@@ -0,0 +1,147 @@
+<?php
+/**
+ * Copyright (c) 2012-2021, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+abstract class ArticlesMultipleTimingsViewTestCase extends AbstractControllerTestCase {
+    protected $_storm_default_to_volatile = true;
+
+
+  public function setUp() {
+    parent::setUp();
+    Class_AdminVar::set('ENABLE_ARTICLES_TIMINGS', 1);
+
+    $heure_du_conte = $this->fixture(Class_Article::class,
+                                     ['id' => 5,
+                                      'titre' => 'Heure du conte',
+                                      'contenu' => 'Pour les grands et petits',
+                                      'events_debut' => '2021-06-08 10:00:00',
+                                      'events_fin' => '2021-06-08 11:00:00']);
+    $heure_du_conte
+      ->addEventTiming($this->fixture(Class_Article_EventTiming::class,
+                                      ['id' => 1,
+                                       'start' => '2021-06-01 10:00:00',
+                                       'end' => '2021-06-01 11:00:00']))
+      ->addEventTiming($this->fixture(Class_Article_EventTiming::class,
+                                      ['id' => 1,
+                                       'start' => '2021-06-08 10:00:00',
+                                       'end' => '2021-06-08 11:00:00']))
+      ->addEventTiming($this->fixture(Class_Article_EventTiming::class,
+                                      ['id' => 1,
+                                       'start' => '2021-06-15 10:00:00',
+                                       'end' => '2021-06-15 11:00:00']))
+      ->assertSave();
+
+    $this->fixture(Class_ArticleCategorie::class,
+                   ['id' => 2,
+                    'libelle' => 'Agenda',
+                    'articles' => [$heure_du_conte]]);
+
+    Storm_Test_ObjectWrapper::onLoaderOfModel(Class_Article::class)
+      ->whenCalled('getArticlesByPreferences')
+      ->answers([$heure_du_conte]);
+
+    $time_source = new TimeSourceForTest('2021-06-08 09:00:00');
+    Class_Article_EventTiming::setTimeSource($time_source);
+  }
+
+
+  public function tearDown() {
+    Class_Article_EventTiming::setTimeSource(null);
+    parent::tearDown();
+  }
+}
+
+
+
+
+class ArticlesMultipleTimingsViewTest extends ArticlesMultipleTimingsViewTestCase {
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/cms/articleview/id/5');
+  }
+
+
+  /** @test */
+  public function articleShouldContainsDivForTimings() {
+    $this->assertXPathContentContains('//div[@class="article_timings"]/h2', 'Dates et Horaires');
+  }
+
+
+  /** @test */
+  public function timingsShouldContainsMardiHuitJuinDeDixAOnzeHeures() {
+    $this->assertXPathContentContains('//div[@class="article_timings"]/ul/li',
+                                      'mardi 08 juin 2021 de 10:00 à 11:00');
+  }
+
+
+  /** @test */
+  public function timingsShouldContainsMardiQuinzeJuinDeDixAOnzeHeures() {
+    $this->assertXPathContentContains('//div[@class="article_timings"]/ul/li',
+                                      'mardi 15 juin 2021 de 10:00 à 11:00');
+  }
+
+
+  /** @test */
+  public function timingsShouldNotContainsPremierJuinDeDixAOnzeHeures() {
+    $this->assertNotXPathContentContains('//div[@class="article_timings"]/ul/li',
+                                         '01 juin');
+  }
+}
+
+
+
+
+class ArticlesMultipleTimingsTemplatesViewTest extends ArticlesMultipleTimingsViewTestCase {
+  public function setUp() {
+    parent::setUp();
+    $this->_buildTemplateProfil(['id' => 4,
+                                 'libelle' => 'Dans le train']);
+
+    $this->dispatch('/cms/articleview/id/5/id_profil/4');
+  }
+
+
+  /** @test */
+  public function articleShouldContainsDivForTimings() {
+    $this->assertXPathContentContains('//div[@class="article_timings"]/h2', 'Dates et Horaires');
+  }
+
+
+  /** @test */
+  public function timingsShouldContainsMardiHuitJuinDeDixAOnzeHeures() {
+    $this->assertXPathContentContains('//div[@class="article_timings"]/ul/li',
+                                      'mardi 08 juin 2021 de 10:00 à 11:00');
+  }
+
+
+  /** @test */
+  public function timingsShouldContainsMardiQuinzeJuinDeDixAOnzeHeures() {
+    $this->assertXPathContentContains('//div[@class="article_timings"]/ul/li',
+                                      'mardi 15 juin 2021 de 10:00 à 11:00');
+  }
+
+
+  /** @test */
+  public function timingsShouldNotContainsPremierJuinDeDixAOnzeHeures() {
+    $this->assertNotXPathContentContains('//div[@class="article_timings"]/ul/li',
+                                         '01 juin');
+  }
+}
diff --git a/tests/scenarios/ExternalAgendas/ExternalAgendasOpenAgendaTest.php b/tests/scenarios/ExternalAgendas/ExternalAgendasOpenAgendaTest.php
index 2dd27dba0ca5d1cdbdec4e0f2f61d10fccc16894..b4a9275cf64d9df1dc1792650cb712833c5eaf35 100644
--- a/tests/scenarios/ExternalAgendas/ExternalAgendasOpenAgendaTest.php
+++ b/tests/scenarios/ExternalAgendas/ExternalAgendasOpenAgendaTest.php
@@ -19,20 +19,18 @@
  * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
  */
 
-
-class ExternalAgendasOpenAgendaImportTest extends ModelTestCase {
-  public function setup() {
-    parent::setup();
-
-    $events_category = $this->fixture('Class_ArticleCategorie',
+abstract class ExternalAgendasOpenAgendaImportTestCase extends ModelTestCase {
+  public function setUp() {
+    parent::setUp();
+    $events_category = $this->fixture(Class_ArticleCategorie::class,
                                       ['id'=>123,
                                        'libelle' => 'Coding Gouter',
-                                       'bib'=> $this->fixture('Class_Bib',
+                                       'bib'=> $this->fixture(Class_Bib::class,
                                                               ['id'=>7,
                                                                'libelle'=>'Joliville'])]);
 
     $agenda_url = 'https://openagenda.com/agendas/36758196/events.json?lang=fr&key=e0f9e6b4302a439f99b78549b9d63fd6';
-    $this->fixture('Class_ExternalAgenda',
+    $this->fixture(Class_ExternalAgenda::class,
                    ['id' => 12,
                     'label' => 'agenda PNB',
                     'url' => $agenda_url,
@@ -61,8 +59,6 @@ class ExternalAgendasOpenAgendaImportTest extends ModelTestCase {
     $time_source = new TimeSourceForTest('2019-11-01 08:00:00');
     Class_ExternalAgenda::setTimeSource($time_source);
     Class_Article::setTimeSource($time_source);
-
-    Class_ExternalAgenda::find(12)->import();
   }
 
 
@@ -71,6 +67,16 @@ class ExternalAgendasOpenAgendaImportTest extends ModelTestCase {
     Class_Article::setTimeSource(null);
     parent::tearDown();
   }
+}
+
+
+
+
+class ExternalAgendasOpenAgendaImportTest extends ExternalAgendasOpenAgendaImportTestCase {
+  public function setup() {
+    parent::setup();
+    Class_ExternalAgenda::find(12)->import();
+  }
 
 
   /** @test */
@@ -117,7 +123,7 @@ class ExternalAgendasOpenAgendaImportTest extends ModelTestCase {
   /** @test */
   public function afterImportWhenNotDeleteOrphanOldArticleShouldBePresent() {
     Class_ExternalAgenda::find(12)->setDeleteOrphanEvents(0);
-    $this->fixture('Class_Article',
+    $this->fixture(Class_Article::class,
                    ['id'    => 234,
                     'titre' => 'Test',
                     'description' => 'test',
diff --git a/tests/scenarios/ExternalAgendas/open-agenda-2.json b/tests/scenarios/ExternalAgendas/open-agenda-2.json
index e01201dfb437b8f466e2a31991f4df8cf98eab4f..620ede530ef8cfb898c58cdc62c8b68892a96d49 100644
--- a/tests/scenarios/ExternalAgendas/open-agenda-2.json
+++ b/tests/scenarios/ExternalAgendas/open-agenda-2.json
@@ -60,38 +60,6 @@
         {
           "start": "2019-12-02T10:00:00.000Z",
           "end": "2019-12-02T10:30:00.000Z"
-        },
-        {
-          "start": "2019-12-09T10:00:00.000Z",
-          "end": "2019-12-09T10:30:00.000Z"
-        },
-        {
-          "start": "2019-12-16T10:00:00.000Z",
-          "end": "2019-12-16T10:30:00.000Z"
-        },
-        {
-          "start": "2019-12-23T10:00:00.000Z",
-          "end": "2019-12-23T10:30:00.000Z"
-        },
-        {
-          "start": "2019-12-30T10:00:00.000Z",
-          "end": "2019-12-30T10:30:00.000Z"
-        },
-        {
-          "start": "2020-01-06T10:00:00.000Z",
-          "end": "2020-01-06T10:30:00.000Z"
-        },
-        {
-          "start": "2020-01-13T10:00:00.000Z",
-          "end": "2020-01-13T10:30:00.000Z"
-        },
-        {
-          "start": "2020-01-20T10:00:00.000Z",
-          "end": "2020-01-20T10:30:00.000Z"
-        },
-        {
-          "start": "2020-01-27T10:00:00.000Z",
-          "end": "2020-01-27T10:30:00.000Z"
         }
       ],
       "location": {
diff --git a/tests/scenarios/Templates/TemplatesMenuTest.php b/tests/scenarios/Templates/TemplatesMenuTest.php
index b2e4e0e3c4a52cb7e0ba76acb4a771caf9273d07..1cf242a1c26727cbf7f1aba61051bf4865146751 100644
--- a/tests/scenarios/Templates/TemplatesMenuTest.php
+++ b/tests/scenarios/Templates/TemplatesMenuTest.php
@@ -1,4 +1,4 @@
-$<?php
+<?php
 /**
  * Copyright (c) 2012-2020, Agence Française Informatique (AFI). All rights reserved.
  *
@@ -18,7 +18,7 @@ $<?php
  * along with BOKEH; if not, write to the Free Software
  * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
  */
-  require_once 'TemplatesTest.php';
+require_once 'TemplatesTest.php';
 
 
 class TemplatesMenuDispatchEditMenuHTest extends TemplatesIntonationTestCase {
@@ -331,7 +331,7 @@ class TemplatesMenuDefaultSettingsTest extends Admin_AbstractControllerTestCase
 
 
 
-class TemplatesMenuTestCase extends AbstractControllerTestCase {
+abstract class TemplatesMenuTestCase extends AbstractControllerTestCase {
 
   protected
     $_storm_default_to_volatile = true,
@@ -493,4 +493,4 @@ class TemplatesMenuNavWithLayoutTest extends TemplatesMenuTestCase {
   public function homeShouldBeRenderInANavTag() {
     $this->assertXPathContentContains('//footer//nav', 'Home');
   }
-}
\ No newline at end of file
+}
diff --git a/tests/scenarios/Templates/TemplatesWidgetTest.php b/tests/scenarios/Templates/TemplatesWidgetTest.php
index c4150955d60a6d5d57d9c7f9069e3d99772ad0ab..f1f616149cf3fe3c7fd3b254b1558bdac50b8bbe 100644
--- a/tests/scenarios/Templates/TemplatesWidgetTest.php
+++ b/tests/scenarios/Templates/TemplatesWidgetTest.php
@@ -236,7 +236,8 @@ class TemplatesWidgetRenderAllAgendaTest extends TemplatesWidgetRenderAllTestCas
 
   /** @test */
   public function townGrandAnnecyShouldBeSelectable() {
-    $this->assertXPathContentContains('//div[contains(@class, "filters")]//li//a[contains(@href, "/cms/render-all/id_module/21/place_town/Grand+Annecy")]', 'Grand Annecy');
+    $this->assertXPathContentContains('//div[contains(@class, "filters")]//li//a[contains(@href, "/cms/render-all/id_module/21/place_town/Grand+Annecy")]',
+                                      'Grand Annecy');
   }
 }