Jelajahi Sumber

feat: group by date (#2641)

* feat: group by date

* test: added more tests for group by date

* fix: print month in abbrev format

* chore: adapt group event changes

* style: remove comment

* fix: change date on changing group

* fix: dont count time in relative group

* fix: check beginning of month is within 30 days

* refactor: unify group id date format

---------

Co-authored-by: nathan <[email protected]>
Mohammad Zolfaghari 1 tahun lalu
induk
melakukan
2f8edf1fd1

+ 1 - 0
frontend/appflowy_flutter/lib/plugins/database_view/application/field/field_info.dart

@@ -32,6 +32,7 @@ class FieldInfo with _$FieldInfo {
       case FieldType.Checkbox:
       case FieldType.MultiSelect:
       case FieldType.SingleSelect:
+      case FieldType.DateTime:
         return true;
       default:
         return false;

+ 1 - 1
frontend/rust-lib/flowy-database2/src/entities/group_entities/configuration.rs

@@ -2,7 +2,7 @@ use crate::services::group::Group;
 use flowy_derive::{ProtoBuf, ProtoBuf_Enum};
 
 #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)]
-pub struct UrlGroupConfigurationPB {
+pub struct URLGroupConfigurationPB {
   #[pb(index = 1)]
   hide_empty: bool,
 }

+ 14 - 12
frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option.rs

@@ -1,3 +1,15 @@
+use std::cmp::Ordering;
+use std::str::FromStr;
+
+use chrono::{DateTime, FixedOffset, Local, NaiveDateTime, NaiveTime, Offset, TimeZone};
+use chrono_tz::Tz;
+use collab::core::any_map::AnyMapExtension;
+use collab_database::fields::{Field, TypeOptionData, TypeOptionDataBuilder};
+use collab_database::rows::Cell;
+use serde::{Deserialize, Serialize};
+
+use flowy_error::{ErrorCode, FlowyError, FlowyResult};
+
 use crate::entities::{DateCellDataPB, DateFilterPB, FieldType};
 use crate::services::cell::{CellDataChangeset, CellDataDecoder};
 use crate::services::field::{
@@ -6,16 +18,6 @@ use crate::services::field::{
   TypeOptionTransform,
 };
 use crate::services::sort::SortCondition;
-use chrono::format::strftime::StrftimeItems;
-use chrono::{DateTime, FixedOffset, Local, NaiveDateTime, NaiveTime, Offset, TimeZone};
-use chrono_tz::Tz;
-use collab::core::any_map::AnyMapExtension;
-use collab_database::fields::{Field, TypeOptionData, TypeOptionDataBuilder};
-use collab_database::rows::Cell;
-use flowy_error::{ErrorCode, FlowyError, FlowyResult};
-use serde::{Deserialize, Serialize};
-use std::cmp::Ordering;
-use std::str::FromStr;
 
 /// The [DateTypeOption] is used by [FieldType::Date], [FieldType::LastEditedTime], and [FieldType::CreatedTime].
 /// So, storing the field type is necessary to distinguish the field type.
@@ -121,9 +123,9 @@ impl DateTypeOption {
         let date_time = DateTime::<Local>::from_utc(naive, offset);
 
         let fmt = self.date_format.format_str();
-        let date = format!("{}", date_time.format_with_items(StrftimeItems::new(fmt)));
+        let date = format!("{}", date_time.format(fmt));
         let fmt = self.time_format.format_str();
-        let time = format!("{}", date_time.format_with_items(StrftimeItems::new(fmt)));
+        let time = format!("{}", date_time.format(fmt));
 
         (date, time)
       },

+ 10 - 2
frontend/rust-lib/flowy-database2/src/services/field/type_options/date_type_option/date_type_option_entities.rs

@@ -1,12 +1,11 @@
 #![allow(clippy::upper_case_acronyms)]
 
-use std::fmt;
-
 use bytes::Bytes;
 use collab::core::any_map::AnyMapExtension;
 use collab_database::rows::{new_cell_builder, Cell};
 use serde::de::Visitor;
 use serde::{Deserialize, Serialize};
+use std::fmt;
 use strum_macros::EnumIter;
 
 use flowy_error::{internal_error, FlowyResult};
@@ -81,6 +80,15 @@ impl From<&Cell> for DateCellData {
   }
 }
 
+impl From<&DateCellDataPB> for DateCellData {
+  fn from(data: &DateCellDataPB) -> Self {
+    Self {
+      timestamp: Some(data.timestamp),
+      include_time: data.include_time,
+    }
+  }
+}
+
 /// Wrapper for DateCellData that also contains the field type.
 /// Handy struct to use when you need to convert a DateCellData to a Cell.
 pub struct DateCellDataWrapper {

+ 4 - 0
frontend/rust-lib/flowy-database2/src/services/group/configuration.rs

@@ -379,6 +379,10 @@ where
       .await
   }
 
+  pub fn get_setting_content(&self) -> String {
+    self.setting.content.clone()
+  }
+
   /// # Arguments
   ///
   /// * `mut_configuration_fn`: mutate the [GroupSetting] and return whether the [GroupSetting] is

+ 598 - 0
frontend/rust-lib/flowy-database2/src/services/group/controller_impls/date_controller.rs

@@ -0,0 +1,598 @@
+use crate::entities::{
+  DateCellDataPB, FieldType, GroupPB, GroupRowsNotificationPB, InsertedGroupPB, InsertedRowPB,
+  RowMetaPB,
+};
+use crate::services::cell::insert_date_cell;
+use crate::services::field::{DateCellData, DateCellDataParser, DateTypeOption};
+use crate::services::group::action::GroupCustomize;
+use crate::services::group::configuration::GroupContext;
+use crate::services::group::controller::{
+  BaseGroupController, GroupController, GroupsBuilder, MoveGroupRowContext,
+};
+use crate::services::group::{
+  make_no_status_group, move_group_row, GeneratedGroupConfig, GeneratedGroups, Group,
+};
+use chrono::{
+  DateTime, Datelike, Days, Duration, Local, NaiveDate, NaiveDateTime, Offset, TimeZone,
+};
+use chrono_tz::Tz;
+use collab_database::database::timestamp;
+use collab_database::fields::Field;
+use collab_database::rows::{new_cell_builder, Cell, Cells, Row, RowDetail};
+use flowy_error::FlowyResult;
+use serde::{Deserialize, Serialize};
+use serde_repr::{Deserialize_repr, Serialize_repr};
+use std::format;
+use std::str::FromStr;
+use std::sync::Arc;
+
+pub trait GroupConfigurationContentSerde: Sized + Send + Sync {
+  fn from_json(s: &str) -> Result<Self, serde_json::Error>;
+  fn to_json(&self) -> Result<String, serde_json::Error>;
+}
+
+#[derive(Default, Serialize, Deserialize)]
+pub struct DateGroupConfiguration {
+  pub hide_empty: bool,
+  pub condition: DateCondition,
+}
+
+impl GroupConfigurationContentSerde for DateGroupConfiguration {
+  fn from_json(s: &str) -> Result<Self, serde_json::Error> {
+    serde_json::from_str(s)
+  }
+  fn to_json(&self) -> Result<String, serde_json::Error> {
+    serde_json::to_string(self)
+  }
+}
+
+#[derive(Serialize_repr, Deserialize_repr)]
+#[repr(u8)]
+pub enum DateCondition {
+  Relative = 0,
+  Day = 1,
+  Week = 2,
+  Month = 3,
+  Year = 4,
+}
+
+impl std::default::Default for DateCondition {
+  fn default() -> Self {
+    DateCondition::Relative
+  }
+}
+
+pub type DateGroupController = BaseGroupController<
+  DateGroupConfiguration,
+  DateTypeOption,
+  DateGroupGenerator,
+  DateCellDataParser,
+>;
+
+pub type DateGroupContext = GroupContext<DateGroupConfiguration>;
+
+impl GroupCustomize for DateGroupController {
+  type CellData = DateCellDataPB;
+
+  fn placeholder_cell(&self) -> Option<Cell> {
+    Some(
+      new_cell_builder(FieldType::DateTime)
+        .insert_str_value("data", "")
+        .build(),
+    )
+  }
+
+  fn can_group(&self, content: &str, cell_data: &Self::CellData) -> bool {
+    content
+      == group_id(
+        &cell_data.into(),
+        self.type_option.as_ref(),
+        &self.context.get_setting_content(),
+      )
+  }
+
+  fn create_or_delete_group_when_cell_changed(
+    &mut self,
+    row_detail: &RowDetail,
+    old_cell_data: Option<&Self::CellData>,
+    cell_data: &Self::CellData,
+  ) -> FlowyResult<(Option<InsertedGroupPB>, Option<GroupPB>)> {
+    let setting_content = self.context.get_setting_content();
+    let mut inserted_group = None;
+    if self
+      .context
+      .get_group(&group_id(
+        &cell_data.into(),
+        self.type_option.as_ref(),
+        &setting_content,
+      ))
+      .is_none()
+    {
+      let group = make_group_from_date_cell(
+        &cell_data.into(),
+        self.type_option.as_ref(),
+        &setting_content,
+      );
+      let mut new_group = self.context.add_new_group(group)?;
+      new_group.group.rows.push(RowMetaPB::from(&row_detail.meta));
+      inserted_group = Some(new_group);
+    }
+
+    // Delete the old group if there are no rows in that group
+    let deleted_group = match old_cell_data.and_then(|old_cell_data| {
+      self.context.get_group(&group_id(
+        &old_cell_data.into(),
+        self.type_option.as_ref(),
+        &setting_content,
+      ))
+    }) {
+      None => None,
+      Some((_, group)) => {
+        if group.rows.len() == 1 {
+          Some(group.clone())
+        } else {
+          None
+        }
+      },
+    };
+
+    let deleted_group = match deleted_group {
+      None => None,
+      Some(group) => {
+        self.context.delete_group(&group.id)?;
+        Some(GroupPB::from(group.clone()))
+      },
+    };
+
+    Ok((inserted_group, deleted_group))
+  }
+
+  fn add_or_remove_row_when_cell_changed(
+    &mut self,
+    row_detail: &RowDetail,
+    cell_data: &Self::CellData,
+  ) -> Vec<GroupRowsNotificationPB> {
+    let mut changesets = vec![];
+    let setting_content = self.context.get_setting_content();
+    self.context.iter_mut_status_groups(|group| {
+      let mut changeset = GroupRowsNotificationPB::new(group.id.clone());
+      if group.id
+        == group_id(
+          &cell_data.into(),
+          self.type_option.as_ref(),
+          &setting_content,
+        )
+      {
+        if !group.contains_row(&row_detail.row.id) {
+          changeset
+            .inserted_rows
+            .push(InsertedRowPB::new(RowMetaPB::from(&row_detail.meta)));
+          group.add_row(row_detail.clone());
+        }
+      } else if group.contains_row(&row_detail.row.id) {
+        group.remove_row(&row_detail.row.id);
+        changeset
+          .deleted_rows
+          .push(row_detail.row.id.clone().into_inner());
+      }
+
+      if !changeset.is_empty() {
+        changesets.push(changeset);
+      }
+    });
+    changesets
+  }
+
+  fn delete_row(&mut self, row: &Row, _cell_data: &Self::CellData) -> Vec<GroupRowsNotificationPB> {
+    let mut changesets = vec![];
+    self.context.iter_mut_groups(|group| {
+      let mut changeset = GroupRowsNotificationPB::new(group.id.clone());
+      if group.contains_row(&row.id) {
+        group.remove_row(&row.id);
+        changeset.deleted_rows.push(row.id.clone().into_inner());
+      }
+
+      if !changeset.is_empty() {
+        changesets.push(changeset);
+      }
+    });
+    changesets
+  }
+
+  fn move_row(
+    &mut self,
+    _cell_data: &Self::CellData,
+    mut context: MoveGroupRowContext,
+  ) -> Vec<GroupRowsNotificationPB> {
+    let mut group_changeset = vec![];
+    self.context.iter_mut_groups(|group| {
+      if let Some(changeset) = move_group_row(group, &mut context) {
+        group_changeset.push(changeset);
+      }
+    });
+    group_changeset
+  }
+
+  fn delete_group_when_move_row(
+    &mut self,
+    _row: &Row,
+    cell_data: &Self::CellData,
+  ) -> Option<GroupPB> {
+    let mut deleted_group = None;
+    let setting_content = self.context.get_setting_content();
+    if let Some((_, group)) = self.context.get_group(&group_id(
+      &cell_data.into(),
+      self.type_option.as_ref(),
+      &setting_content,
+    )) {
+      if group.rows.len() == 1 {
+        deleted_group = Some(GroupPB::from(group.clone()));
+      }
+    }
+    if deleted_group.is_some() {
+      let _ = self
+        .context
+        .delete_group(&deleted_group.as_ref().unwrap().group_id);
+    }
+    deleted_group
+  }
+}
+
+impl GroupController for DateGroupController {
+  fn did_update_field_type_option(&mut self, _field: &Arc<Field>) {}
+
+  fn will_create_row(&mut self, cells: &mut Cells, field: &Field, group_id: &str) {
+    match self.context.get_group(group_id) {
+      None => tracing::warn!("Can not find the group: {}", group_id),
+      Some((_, _)) => {
+        let date = DateTime::parse_from_str(&group_id, GROUP_ID_DATE_FORMAT).unwrap();
+        let cell = insert_date_cell(date.timestamp(), None, field);
+        cells.insert(field.id.clone(), cell);
+      },
+    }
+  }
+
+  fn did_create_row(&mut self, row_detail: &RowDetail, group_id: &str) {
+    if let Some(group) = self.context.get_mut_group(group_id) {
+      group.add_row(row_detail.clone())
+    }
+  }
+}
+
+pub struct DateGroupGenerator();
+impl GroupsBuilder for DateGroupGenerator {
+  type Context = DateGroupContext;
+  type TypeOptionType = DateTypeOption;
+
+  fn build(
+    field: &Field,
+    context: &Self::Context,
+    type_option: &Option<Self::TypeOptionType>,
+  ) -> GeneratedGroups {
+    // Read all the cells for the grouping field
+    let cells = futures::executor::block_on(context.get_all_cells());
+
+    // Generate the groups
+    let mut group_configs: Vec<GeneratedGroupConfig> = cells
+      .into_iter()
+      .flat_map(|value| value.into_date_field_cell_data())
+      .filter(|cell| cell.timestamp.is_some())
+      .map(|cell| {
+        let group =
+          make_group_from_date_cell(&cell, type_option.as_ref(), &context.get_setting_content());
+        GeneratedGroupConfig {
+          filter_content: group.id.clone(),
+          group,
+        }
+      })
+      .collect();
+    group_configs.sort_by(|a, b| a.filter_content.cmp(&b.filter_content));
+
+    let no_status_group = Some(make_no_status_group(field));
+    GeneratedGroups {
+      no_status_group,
+      group_configs,
+    }
+  }
+}
+
+fn make_group_from_date_cell(
+  cell_data: &DateCellData,
+  type_option: Option<&DateTypeOption>,
+  setting_content: &String,
+) -> Group {
+  let group_id = group_id(cell_data, type_option, setting_content);
+  Group::new(
+    group_id.clone(),
+    group_name_from_id(&group_id, type_option, setting_content),
+  )
+}
+
+const GROUP_ID_DATE_FORMAT: &'static str = "%Y/%m/%d";
+
+fn group_id(
+  cell_data: &DateCellData,
+  type_option: Option<&DateTypeOption>,
+  setting_content: &String,
+) -> String {
+  let binding = DateTypeOption::default();
+  let type_option = type_option.unwrap_or(&binding);
+  let config = DateGroupConfiguration::from_json(setting_content).unwrap_or_default();
+  let date_time = date_time_from_timestamp(cell_data.timestamp, &type_option.timezone_id);
+
+  let date_format = GROUP_ID_DATE_FORMAT;
+  let month_format = &date_format.replace("%d", "01");
+  let year_format = &month_format.replace("%m", "01");
+
+  let date = match config.condition {
+    DateCondition::Day => date_time.format(date_format),
+    DateCondition::Month => date_time.format(month_format),
+    DateCondition::Year => date_time.format(year_format),
+    DateCondition::Week => date_time
+      .checked_sub_days(Days::new(date_time.weekday().num_days_from_monday() as u64))
+      .unwrap()
+      .format(date_format),
+    DateCondition::Relative => {
+      let now = date_time_from_timestamp(Some(timestamp()), &type_option.timezone_id).date_naive();
+      let date_time = date_time.date_naive();
+
+      let diff = date_time.signed_duration_since(now).num_days();
+      let result = if diff == 0 {
+        Some(now)
+      } else if diff == -1 {
+        now.checked_add_signed(Duration::days(-1))
+      } else if diff == 1 {
+        now.checked_add_signed(Duration::days(1))
+      } else if diff >= -7 && diff < -1 {
+        now.checked_add_signed(Duration::days(-7))
+      } else if diff > 1 && diff <= 7 {
+        now.checked_add_signed(Duration::days(2))
+      } else if diff >= -30 && diff < -7 {
+        now.checked_add_signed(Duration::days(-30))
+      } else if diff > 7 && diff <= 30 {
+        now.checked_add_signed(Duration::days(8))
+      } else {
+        let mut res = date_time
+          .checked_sub_days(Days::new(date_time.day() as u64 - 1))
+          .unwrap();
+        // if beginning of the month is within next 30 days of current day, change to
+        // first day which is greater than 30 days far from current day.
+        let diff = res.signed_duration_since(now).num_days();
+        if diff > 7 && diff <= 30 {
+          res = res
+            .checked_add_days(Days::new((30 - diff + 1) as u64))
+            .unwrap();
+        }
+        Some(res)
+      };
+
+      result.unwrap().format(GROUP_ID_DATE_FORMAT)
+    },
+  };
+
+  date.to_string()
+}
+
+fn group_name_from_id(
+  group_id: &String,
+  type_option: Option<&DateTypeOption>,
+  setting_content: &String,
+) -> String {
+  let binding = DateTypeOption::default();
+  let type_option = type_option.unwrap_or(&binding);
+  let config = DateGroupConfiguration::from_json(setting_content).unwrap_or_default();
+  let date = NaiveDate::parse_from_str(group_id, GROUP_ID_DATE_FORMAT).unwrap();
+
+  let tmp;
+  match config.condition {
+    DateCondition::Day => {
+      tmp = format!(
+        "{} {}, {}",
+        date.format("%b").to_string(),
+        date.day(),
+        date.year(),
+      );
+      tmp
+    },
+    DateCondition::Week => {
+      let begin_of_week = date
+        .checked_sub_days(Days::new(date.weekday().num_days_from_monday() as u64))
+        .unwrap()
+        .format("%d");
+      let end_of_week = date
+        .checked_add_days(Days::new(6 - date.weekday().num_days_from_monday() as u64))
+        .unwrap()
+        .format("%d");
+
+      tmp = format!(
+        "Week of {} {}-{} {}",
+        date.format("%b").to_string(),
+        begin_of_week.to_string(),
+        end_of_week.to_string(),
+        date.year()
+      );
+      tmp
+    },
+    DateCondition::Month => {
+      tmp = format!("{} {}", date.format("%b").to_string(), date.year(),);
+      tmp
+    },
+    DateCondition::Year => date.year().to_string(),
+    DateCondition::Relative => {
+      let now = date_time_from_timestamp(Some(timestamp()), &type_option.timezone_id);
+
+      let diff = date.signed_duration_since(now.date_naive());
+      let result = match diff.num_days() {
+        0 => "Today",
+        -1 => "Yesterday",
+        1 => "Tomorrow",
+        -7 => "Last 7 days",
+        2 => "Next 7 days",
+        -30 => "Last 30 days",
+        8 => "Next 30 days",
+        _ => {
+          tmp = format!("{} {}", date.format("%b").to_string(), date.year(),);
+          &tmp
+        },
+      };
+
+      result.to_string()
+    },
+  }
+}
+
+fn date_time_from_timestamp(timestamp: Option<i64>, timezone_id: &String) -> DateTime<Local> {
+  match timestamp {
+    Some(timestamp) => {
+      let naive = NaiveDateTime::from_timestamp_opt(timestamp, 0).unwrap();
+      let offset = match Tz::from_str(timezone_id) {
+        Ok(timezone) => timezone.offset_from_utc_datetime(&naive).fix(),
+        Err(_) => *Local::now().offset(),
+      };
+
+      DateTime::<Local>::from_utc(naive, offset)
+    },
+    None => DateTime::default(),
+  }
+}
+
+#[cfg(test)]
+mod tests {
+  use crate::services::{
+    field::{date_type_option::DateTypeOption, DateCellData},
+    group::controller_impls::date_controller::{
+      group_id, group_name_from_id, GROUP_ID_DATE_FORMAT,
+    },
+  };
+  use chrono::{offset, Days, Duration, NaiveDateTime};
+  use std::vec;
+
+  #[test]
+  fn group_id_name_test() {
+    struct GroupIDTest<'a> {
+      cell_data: DateCellData,
+      setting_content: String,
+      exp_group_id: String,
+      exp_group_name: String,
+      type_option: &'a DateTypeOption,
+    }
+
+    let mar_14_2022 = NaiveDateTime::from_timestamp_opt(1647251762, 0).unwrap();
+    let mar_14_2022_cd = DateCellData {
+      timestamp: Some(mar_14_2022.timestamp()),
+      include_time: false,
+    };
+    let today = offset::Local::now();
+    let three_days_before = today.checked_add_signed(Duration::days(-3)).unwrap();
+
+    let mut local_date_type_option = DateTypeOption::default();
+    local_date_type_option.timezone_id = today.offset().to_string();
+    let mut default_date_type_option = DateTypeOption::default();
+    default_date_type_option.timezone_id = "".to_string();
+
+    let tests = vec![
+      GroupIDTest {
+        cell_data: mar_14_2022_cd.clone(),
+        type_option: &local_date_type_option,
+        setting_content: r#"{"condition": 0, "hide_empty": false}"#.to_string(),
+        exp_group_id: "2022/03/01".to_string(),
+        exp_group_name: "Mar 2022".to_string(),
+      },
+      GroupIDTest {
+        cell_data: DateCellData {
+          timestamp: Some(today.timestamp()),
+          include_time: false,
+        },
+        type_option: &local_date_type_option,
+        setting_content: r#"{"condition": 0, "hide_empty": false}"#.to_string(),
+        exp_group_id: today.format(GROUP_ID_DATE_FORMAT).to_string(),
+        exp_group_name: "Today".to_string(),
+      },
+      GroupIDTest {
+        cell_data: DateCellData {
+          timestamp: Some(three_days_before.timestamp()),
+          include_time: false,
+        },
+        type_option: &local_date_type_option,
+        setting_content: r#"{"condition": 0, "hide_empty": false}"#.to_string(),
+        exp_group_id: today
+          .checked_sub_days(Days::new(7))
+          .unwrap()
+          .format(GROUP_ID_DATE_FORMAT)
+          .to_string(),
+        exp_group_name: "Last 7 days".to_string(),
+      },
+      GroupIDTest {
+        cell_data: mar_14_2022_cd.clone(),
+        type_option: &local_date_type_option,
+        setting_content: r#"{"condition": 1, "hide_empty": false}"#.to_string(),
+        exp_group_id: "2022/03/14".to_string(),
+        exp_group_name: "Mar 14, 2022".to_string(),
+      },
+      GroupIDTest {
+        cell_data: DateCellData {
+          timestamp: Some(
+            mar_14_2022
+              .checked_add_signed(Duration::days(3))
+              .unwrap()
+              .timestamp(),
+          ),
+          include_time: false,
+        },
+        type_option: &local_date_type_option,
+        setting_content: r#"{"condition": 2, "hide_empty": false}"#.to_string(),
+        exp_group_id: "2022/03/14".to_string(),
+        exp_group_name: "Week of Mar 14-20 2022".to_string(),
+      },
+      GroupIDTest {
+        cell_data: mar_14_2022_cd.clone(),
+        type_option: &local_date_type_option,
+        setting_content: r#"{"condition": 3, "hide_empty": false}"#.to_string(),
+        exp_group_id: "2022/03/01".to_string(),
+        exp_group_name: "Mar 2022".to_string(),
+      },
+      GroupIDTest {
+        cell_data: mar_14_2022_cd.clone(),
+        type_option: &local_date_type_option,
+        setting_content: r#"{"condition": 4, "hide_empty": false}"#.to_string(),
+        exp_group_id: "2022/01/01".to_string(),
+        exp_group_name: "2022".to_string(),
+      },
+      GroupIDTest {
+        cell_data: DateCellData {
+          timestamp: Some(1685715999),
+          include_time: false,
+        },
+        type_option: &default_date_type_option,
+        setting_content: r#"{"condition": 1, "hide_empty": false}"#.to_string(),
+        exp_group_id: "2023/06/02".to_string(),
+        exp_group_name: "".to_string(),
+      },
+      GroupIDTest {
+        cell_data: DateCellData {
+          timestamp: Some(1685802386),
+          include_time: false,
+        },
+        type_option: &default_date_type_option,
+        setting_content: r#"{"condition": 1, "hide_empty": false}"#.to_string(),
+        exp_group_id: "2023/06/03".to_string(),
+        exp_group_name: "".to_string(),
+      },
+    ];
+
+    for (i, test) in tests.iter().enumerate() {
+      let group_id = group_id(
+        &test.cell_data,
+        Some(test.type_option),
+        &test.setting_content,
+      );
+      assert_eq!(test.exp_group_id, group_id, "test {}", i);
+
+      if test.exp_group_name != "" {
+        let group_name =
+          group_name_from_id(&group_id, Some(test.type_option), &test.setting_content);
+        assert_eq!(test.exp_group_name, group_name, "test {}", i);
+      }
+    }
+  }
+}

+ 2 - 0
frontend/rust-lib/flowy-database2/src/services/group/controller_impls/mod.rs

@@ -1,9 +1,11 @@
 mod checkbox_controller;
+mod date_controller;
 mod default_controller;
 mod select_option_controller;
 mod url_controller;
 
 pub use checkbox_controller::*;
+pub use date_controller::*;
 pub use default_controller::*;
 pub use select_option_controller::*;
 pub use url_controller::*;

+ 13 - 1
frontend/rust-lib/flowy-database2/src/services/group/controller_impls/select_option_controller/util.rs

@@ -1,10 +1,13 @@
+use chrono::NaiveDateTime;
 use collab_database::fields::Field;
 use collab_database::rows::{Cell, Row, RowDetail};
 
 use crate::entities::{
   FieldType, GroupRowsNotificationPB, InsertedRowPB, RowMetaPB, SelectOptionCellDataPB,
 };
-use crate::services::cell::{insert_checkbox_cell, insert_select_option_cell, insert_url_cell};
+use crate::services::cell::{
+  insert_checkbox_cell, insert_date_cell, insert_select_option_cell, insert_url_cell,
+};
 use crate::services::field::{SelectOption, CHECK};
 use crate::services::group::controller::MoveGroupRowContext;
 use crate::services::group::{GeneratedGroupConfig, Group, GroupData};
@@ -170,6 +173,15 @@ pub fn make_inserted_cell(group_id: &str, field: &Field) -> Option<Cell> {
       let cell = insert_url_cell(group_id.to_owned(), field);
       Some(cell)
     },
+    FieldType::DateTime => {
+      let date = NaiveDateTime::parse_from_str(
+        &format!("{} 00:00:00", group_id).to_string(),
+        "%Y/%m/%d %H:%M:%S",
+      )
+      .unwrap();
+      let cell = insert_date_cell(date.timestamp(), None, field);
+      Some(cell)
+    },
     _ => {
       tracing::warn!("Unknown field type: {:?}", field_type);
       None

+ 13 - 3
frontend/rust-lib/flowy-database2/src/services/group/group_builder.rs

@@ -7,10 +7,9 @@ use collab_database::views::DatabaseLayout;
 use flowy_error::FlowyResult;
 
 use crate::entities::FieldType;
-use crate::services::group::configuration::GroupSettingReader;
-use crate::services::group::controller::GroupController;
 use crate::services::group::{
-  CheckboxGroupContext, CheckboxGroupController, DefaultGroupController, Group, GroupSetting,
+  CheckboxGroupContext, CheckboxGroupController, DateGroupContext, DateGroupController,
+  DefaultGroupController, Group, GroupController, GroupSetting, GroupSettingReader,
   GroupSettingWriter, MultiSelectGroupController, MultiSelectOptionGroupContext,
   SingleSelectGroupController, SingleSelectOptionGroupContext, URLGroupContext, URLGroupController,
 };
@@ -95,6 +94,17 @@ where
       let controller = URLGroupController::new(&grouping_field, configuration).await?;
       group_controller = Box::new(controller);
     },
+    FieldType::DateTime => {
+      let configuration = DateGroupContext::new(
+        view_id,
+        grouping_field.clone(),
+        configuration_reader,
+        configuration_writer,
+      )
+      .await?;
+      let controller = DateGroupController::new(&grouping_field, configuration).await?;
+      group_controller = Box::new(controller);
+    },
     _ => {
       group_controller = Box::new(DefaultGroupController::new(&grouping_field));
     },

+ 208 - 0
frontend/rust-lib/flowy-database2/tests/database/group_test/date_group_test.rs

@@ -0,0 +1,208 @@
+use crate::database::group_test::script::DatabaseGroupTest;
+use crate::database::group_test::script::GroupScript::*;
+use chrono::NaiveDateTime;
+use chrono::{offset, Duration};
+use collab_database::database::gen_row_id;
+use collab_database::rows::CreateRowParams;
+use flowy_database2::entities::FieldType;
+use flowy_database2::services::cell::CellBuilder;
+use flowy_database2::services::field::DateCellData;
+use std::collections::HashMap;
+use std::vec;
+
+#[tokio::test]
+async fn group_by_date_test() {
+  let date_diffs = vec![-1, 0, 7, -15, -1];
+  let mut test = DatabaseGroupTest::new().await;
+  let date_field = test.get_field(FieldType::DateTime).await;
+
+  for diff in date_diffs {
+    let timestamp = offset::Local::now()
+      .checked_add_signed(Duration::days(diff))
+      .unwrap()
+      .timestamp()
+      .to_string();
+    let mut cells = HashMap::new();
+    cells.insert(date_field.id.clone(), timestamp);
+    let cells = CellBuilder::with_cells(cells, &[date_field.clone()]).build();
+
+    let params = CreateRowParams {
+      id: gen_row_id(),
+      cells,
+      height: 60,
+      visibility: true,
+      prev_row_id: None,
+      timestamp: 0,
+    };
+    let res = test.editor.create_row(&test.view_id, None, params).await;
+    assert!(res.is_ok());
+  }
+
+  let today = offset::Local::now();
+  let last_day = today
+    .checked_add_signed(Duration::days(-1))
+    .unwrap()
+    .format("%Y/%m/%d")
+    .to_string();
+  let last_30_days = today
+    .checked_add_signed(Duration::days(-30))
+    .unwrap()
+    .format("%Y/%m/%d")
+    .to_string();
+  let next_7_days = today
+    .checked_add_signed(Duration::days(2))
+    .unwrap()
+    .format("%Y/%m/%d")
+    .to_string();
+
+  let scripts = vec![
+    GroupByField {
+      field_id: date_field.id.clone(),
+    },
+    AssertGroupCount(7),
+    AssertGroupRowCount {
+      group_index: 0,
+      row_count: 0,
+    },
+    // Added via `make_test_board`
+    AssertGroupIDName {
+      group_index: 1,
+      group_id: "2022/03/01".to_string(),
+      group_name: "Mar 2022".to_string(),
+    },
+    AssertGroupRowCount {
+      group_index: 1,
+      row_count: 3,
+    },
+    // Added via `make_test_board`
+    AssertGroupIDName {
+      group_index: 2,
+      group_id: "2022/11/01".to_string(),
+      group_name: "Nov 2022".to_string(),
+    },
+    AssertGroupRowCount {
+      group_index: 2,
+      row_count: 2,
+    },
+    AssertGroupIDName {
+      group_index: 3,
+      group_id: last_30_days,
+      group_name: "Last 30 days".to_string(),
+    },
+    AssertGroupRowCount {
+      group_index: 3,
+      row_count: 1,
+    },
+    AssertGroupIDName {
+      group_index: 4,
+      group_id: last_day,
+      group_name: "Yesterday".to_string(),
+    },
+    AssertGroupRowCount {
+      group_index: 4,
+      row_count: 2,
+    },
+    AssertGroupIDName {
+      group_index: 5,
+      group_id: today.format("%Y/%m/%d").to_string(),
+      group_name: "Today".to_string(),
+    },
+    AssertGroupRowCount {
+      group_index: 5,
+      row_count: 1,
+    },
+    AssertGroupIDName {
+      group_index: 6,
+      group_id: next_7_days,
+      group_name: "Next 7 days".to_string(),
+    },
+    AssertGroupRowCount {
+      group_index: 6,
+      row_count: 1,
+    },
+  ];
+  test.run_scripts(scripts).await;
+}
+
+#[tokio::test]
+async fn change_row_group_on_date_cell_changed_test() {
+  let mut test = DatabaseGroupTest::new().await;
+  let date_field = test.get_field(FieldType::DateTime).await;
+  let scripts = vec![
+    GroupByField {
+      field_id: date_field.id.clone(),
+    },
+    AssertGroupCount(3),
+    // Nov 2, 2022
+    UpdateGroupedCellWithData {
+      from_group_index: 1,
+      row_index: 0,
+      cell_data: "1667408732".to_string(),
+    },
+    AssertGroupRowCount {
+      group_index: 1,
+      row_count: 2,
+    },
+    AssertGroupRowCount {
+      group_index: 2,
+      row_count: 3,
+    },
+  ];
+  test.run_scripts(scripts).await;
+}
+
+#[tokio::test]
+async fn change_date_on_moving_row_to_another_group() {
+  let mut test = DatabaseGroupTest::new().await;
+  let date_field = test.get_field(FieldType::DateTime).await;
+  let scripts = vec![
+    GroupByField {
+      field_id: date_field.id.clone(),
+    },
+    AssertGroupCount(3),
+    AssertGroupRowCount {
+      group_index: 1,
+      row_count: 3,
+    },
+    AssertGroupRowCount {
+      group_index: 2,
+      row_count: 2,
+    },
+    MoveRow {
+      from_group_index: 1,
+      from_row_index: 0,
+      to_group_index: 2,
+      to_row_index: 0,
+    },
+    AssertGroupRowCount {
+      group_index: 1,
+      row_count: 2,
+    },
+    AssertGroupRowCount {
+      group_index: 2,
+      row_count: 3,
+    },
+    AssertGroupIDName {
+      group_index: 2,
+      group_id: "2022/11/01".to_string(),
+      group_name: "Nov 2022".to_string(),
+    },
+  ];
+  test.run_scripts(scripts).await;
+
+  let group = test.group_at_index(2).await;
+  let rows = group.clone().rows;
+  let row_id = &rows.get(0).unwrap().id;
+  let row_detail = test
+    .get_rows()
+    .await
+    .into_iter()
+    .find(|r| r.row.id.to_string() == row_id.to_string())
+    .unwrap();
+  let cell = row_detail.row.cells.get(&date_field.id.clone()).unwrap();
+  let date_cell = DateCellData::from(cell);
+
+  let date_time =
+    NaiveDateTime::parse_from_str("2022/11/01 00:00:00", "%Y/%m/%d %H:%M:%S").unwrap();
+  assert_eq!(date_time.timestamp(), date_cell.timestamp.unwrap());
+}

+ 1 - 0
frontend/rust-lib/flowy-database2/tests/database/group_test/mod.rs

@@ -1,3 +1,4 @@
+mod date_group_test;
 mod script;
 mod test;
 mod url_group_test;

+ 26 - 21
frontend/rust-lib/flowy-database2/tests/database/group_test/script.rs

@@ -4,7 +4,7 @@ use collab_database::rows::{CreateRowParams, RowId};
 
 use flowy_database2::entities::{FieldType, GroupPB, RowMetaPB};
 use flowy_database2::services::cell::{
-  delete_select_option_cell, insert_select_option_cell, insert_url_cell,
+  delete_select_option_cell, insert_date_cell, insert_select_option_cell, insert_url_cell,
 };
 use flowy_database2::services::field::{
   edit_single_select_type_option, SelectOption, SelectTypeOptionSharedAction,
@@ -62,6 +62,11 @@ pub enum GroupScript {
   GroupByField {
     field_id: String,
   },
+  AssertGroupIDName {
+    group_index: usize,
+    group_id: String,
+    group_name: String,
+  },
 }
 
 pub struct DatabaseGroupTest {
@@ -203,6 +208,9 @@ impl DatabaseGroupTest {
         let field_type = FieldType::from(field.field_type);
         let cell = match field_type {
           FieldType::URL => insert_url_cell(cell_data, &field),
+          FieldType::DateTime => {
+            insert_date_cell(cell_data.parse::<i64>().unwrap(), Some(true), &field)
+          },
           _ => {
             panic!("Unsupported group field type");
           },
@@ -252,6 +260,15 @@ impl DatabaseGroupTest {
           .await
           .unwrap();
       },
+      GroupScript::AssertGroupIDName {
+        group_index,
+        group_id,
+        group_name,
+      } => {
+        let group = self.group_at_index(group_index).await;
+        assert_eq!(group_id, group.group_id, "group index: {}", group_index);
+        assert_eq!(group_name, group.group_name, "group index: {}", group_index);
+      },
     }
   }
 
@@ -267,27 +284,11 @@ impl DatabaseGroupTest {
 
   #[allow(dead_code)]
   pub async fn get_multi_select_field(&self) -> Field {
-    self
-      .inner
-      .get_fields()
-      .into_iter()
-      .find(|field_rev| {
-        let field_type = FieldType::from(field_rev.field_type);
-        field_type.is_multi_select()
-      })
-      .unwrap()
+    self.get_field(FieldType::MultiSelect).await
   }
 
   pub async fn get_single_select_field(&self) -> Field {
-    self
-      .inner
-      .get_fields()
-      .into_iter()
-      .find(|field| {
-        let field_type = FieldType::from(field.field_type);
-        field_type.is_single_select()
-      })
-      .unwrap()
+    self.get_field(FieldType::SingleSelect).await
   }
 
   pub async fn edit_single_select_type_option(
@@ -306,13 +307,17 @@ impl DatabaseGroupTest {
   }
 
   pub async fn get_url_field(&self) -> Field {
+    self.get_field(FieldType::URL).await
+  }
+
+  pub async fn get_field(&self, field_type: FieldType) -> Field {
     self
       .inner
       .get_fields()
       .into_iter()
       .find(|field| {
-        let field_type = FieldType::from(field.field_type);
-        field_type.is_url()
+        let ft = FieldType::from(field.field_type);
+        ft == field_type
       })
       .unwrap()
   }