Repository: GothenburgBitFactory/timewarrior (cpp)

Issue:
Wrap annotations in summary
Please wrap the annotation into multiple colums, otherwise it doesn't make too much sense if you can't read it in the summary:

```
 timew summary :annotations :id oas    

Wk Date       Day ID Tags      Annotation        Start     End    Time   Total
W6 2019-02-07 Thu @1 +123, oas Working my a... 0:31:51 0:32:50 0:00:59 0:00:59
                                                                              
                                                                       0:00:59
```

i.e. like this:

```
 timew summary :annotations :id oas    

Wk Date       Day ID Tags      Annotation        Start     End    Time   Total
W6 2019-02-07 Thu @1 +123, oas Working my ass  0:31:51 0:32:50 0:00:59 0:00:59
                               off after an al-
                               ready long day                     
                                                                       0:00:59
```


Discussion:
While you are at it, it will also be useful to allow the summary report to be wider.  This will be very useful if the report contains more or longer fields as shown above (numerous tags and annotations).

Perhaps this relates to all timew output which might be premised on a standard screen width.

A wider screen width (or report width) preference could be specified in timewarrior.cfg.
@grovesteyn You write that you can configure this in timewarrior.cfg, but how? Unfortunately the documentation is very poor and I can't find anything at all. What do I have to enter in the config so that the column width of the annotation becomes wider with the "summary" command?
> @grovesteyn You write that you can configure this in timewarrior.cfg, but how?

Currently, the maximum length of 15 chars for the annotations column in the summary is [hard-coded and can't be configured](https://github.com/GothenburgBitFactory/timewarrior/blob/c8a85850ea01befc652e57fd6c2de73a1ed0eee9/src/commands/CmdSummary.cpp#L233).

(I believe @grovesteyn was proposing one possible way to change the configuration (by saying "could be specified in timewarrior.cfg"), and not referring to something that is already implemented.)

Making the annotations column length in summary configurable seems to be a fairly simple change, I wonder whether author(s) would be interested in a patch and, in such case, whether they have any concerns that should be taken care of in implementation proposals.
I'd be happy if a PR making the maximum width of the annotations column configurable would also take unicode character widths into account (see https://github.com/GothenburgBitFactory/timewarrior/pull/529#issuecomment-1508193650).

A more complete solution (apart from wrapping) would be to let the annotations column width grow until the summary table hits the terminal width an only then to apply the cut off. 🤔 (Currently, the summary table width is [fixed to 1024 characters](https://github.com/GothenburgBitFactory/timewarrior/blob/84fdf76f9aba22d9badbcbdf92942714cb5e254f/src/commands/CmdSummary.cpp#L124).)



Patch:
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -14,6 +14,8 @@ set (timew_SRCS AtomicFile.cpp AtomicFile.h
                 DatetimeParser.cpp DatetimeParser.h
                 Exclusion.cpp  Exclusion.h
                 Extensions.cpp Extensions.h
+                ExtensionsTable.cpp ExtensionsTable.h
+                GapsTable.cpp GapsTable.h
                 Interval.cpp   Interval.h
                 IntervalFactory.cpp IntervalFactory.h
                 IntervalFilter.cpp IntervalFilter.h
@@ -25,8 +27,11 @@ set (timew_SRCS AtomicFile.cpp AtomicFile.h
                 Journal.cpp    Journal.h
                 Range.cpp      Range.h
                 Rules.cpp      Rules.h
+                SummaryTable.cpp SummaryTable.h
+                TagDescription.cpp TagDescription.h
                 TagInfo.cpp    TagInfo.h
                 TagInfoDatabase.cpp TagInfoDatabase.h
+                TagsTable.cpp TagsTable.h
                 Transaction.cpp Transaction.h
                 TransactionsFactory.cpp TransactionsFactory.h
                 UndoAction.cpp UndoAction.h
diff --git a/src/ExtensionsTable.cpp b/src/ExtensionsTable.cpp
new file mode 100644
--- /dev/null
+++ b/src/ExtensionsTable.cpp
@@ -0,0 +1,89 @@
+//////////////////////////////////////////////////////////////////////////////
+//
+//  Copyright 2023, Gothenburg Bit Factory.
+//
+//  Permission is hereby granted, free of charge, to any person obtaining a copy
+//  of this software and associated documentation files (the "Software"), to deal
+//  in the Software without restriction, including without limitation the rights
+//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+//  copies of the Software, and to permit persons to whom the Software is
+//  furnished to do so, subject to the following conditions:
+//
+//  The above copyright notice and this permission notice shall be included
+//  in all copies or substantial portions of the Software.
+//
+//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+//  OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+//  THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+//  SOFTWARE.
+//
+//  https://www.opensource.org/licenses/mit-license.php
+//
+//////////////////////////////////////////////////////////////////////////////
+
+#include <Extensions.h>
+#include <ExtensionsTable.h>
+#include <FS.h>
+#include <timew.h>
+
+///////////////////////////////////////////////////////////////////////////////
+ExtensionsTable::Builder ExtensionsTable::builder ()
+{
+  return {};
+}
+
+///////////////////////////////////////////////////////////////////////////////
+ExtensionsTable::Builder& ExtensionsTable::Builder::withExtensions (const Extensions& extensions)
+{
+  _extensions = extensions;
+  return *this;
+}
+
+///////////////////////////////////////////////////////////////////////////////
+Table ExtensionsTable::Builder::build ()
+{
+  int terminalWidth = getTerminalWidth ();
+
+  Table table;
+  table.width (terminalWidth);
+  table.colorHeader (Color ("underline"));
+  table.add ("Extension", true);
+  table.add ("Status", true);
+
+  for (auto &ext: _extensions.all ())
+  {
+    File program (ext);
+
+    // Show program name.
+    auto row = table.addRow ();
+    table.set (row, 0, program.name ());
+
+    // Show extension status.
+    std::string status;
+
+    if (!program.readable ())
+    {
+      status = "Not readable";
+    }
+    else if (!program.executable ())
+    {
+      status = "No executable";
+    }
+    else
+    {
+      status = "Active";
+    }
+
+    if (program.is_link ())
+    {
+      status += " (link)";
+    }
+
+    table.set (row, 1, status);
+  }
+
+  return table;
+}
diff --git a/src/ExtensionsTable.h b/src/ExtensionsTable.h
new file mode 100644
--- /dev/null
+++ b/src/ExtensionsTable.h
@@ -0,0 +1,50 @@
+//////////////////////////////////////////////////////////////////////////////
+//
+//  Copyright 2023, Gothenburg Bit Factory.
+//
+//  Permission is hereby granted, free of charge, to any person obtaining a copy
+//  of this software and associated documentation files (the "Software"), to deal
+//  in the Software without restriction, including without limitation the rights
+//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+//  copies of the Software, and to permit persons to whom the Software is
+//  furnished to do so, subject to the following conditions:
+//
+//  The above copyright notice and this permission notice shall be included
+//  in all copies or substantial portions of the Software.
+//
+//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+//  OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+//  THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+//  SOFTWARE.
+//
+//  https://www.opensource.org/licenses/mit-license.php
+//
+//////////////////////////////////////////////////////////////////////////////
+
+#ifndef INCLUDED_EXTENSIONSTABLE
+#define INCLUDED_EXTENSIONSTABLE
+
+#include <Extensions.h>
+#include <Table.h>
+
+class ExtensionsTable
+{
+  class Builder
+  {
+  public:
+    Builder& withExtensions (const Extensions &);
+
+    Table build ();
+
+  private:
+    Extensions _extensions;
+  };
+
+public:
+  static Builder builder ();
+};
+
+#endif //INCLUDED_EXTENSIONSTABLE
diff --git a/src/GapsTable.cpp b/src/GapsTable.cpp
new file mode 100644
--- /dev/null
+++ b/src/GapsTable.cpp
@@ -0,0 +1,116 @@
+//////////////////////////////////////////////////////////////////////////////
+//
+//  Copyright 2023, Gothenburg Bit Factory.
+//
+//  Permission is hereby granted, free of charge, to any person obtaining a copy
+//  of this software and associated documentation files (the "Software"), to deal
+//  in the Software without restriction, including without limitation the rights
+//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+//  copies of the Software, and to permit persons to whom the Software is
+//  furnished to do so, subject to the following conditions:
+//
+//  The above copyright notice and this permission notice shall be included
+//  in all copies or substantial portions of the Software.
+//
+//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+//  OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+//  THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+//  SOFTWARE.
+//
+//  https://www.opensource.org/licenses/mit-license.php
+//
+//////////////////////////////////////////////////////////////////////////////
+
+#include <Duration.h>
+#include <GapsTable.h>
+#include <format.h>
+#include <timew.h>
+
+///////////////////////////////////////////////////////////////////////////////
+GapsTable::Builder GapsTable::builder ()
+{
+  return {};
+}
+
+///////////////////////////////////////////////////////////////////////////////
+GapsTable::Builder& GapsTable::Builder::withRange (const Range &range)
+{
+  _range = range;
+  return *this;
+}
+
+///////////////////////////////////////////////////////////////////////////////
+GapsTable::Builder& GapsTable::Builder::withIntervals (const std::vector <Range> &intervals)
+{
+  _intervals = intervals;
+  return *this;
+}
+
+///////////////////////////////////////////////////////////////////////////////
+Table GapsTable::Builder::build ()
+{
+  int terminalWidth = getTerminalWidth ();
+
+  Table table;
+  table.width (terminalWidth);
+  table.colorHeader (Color ("underline"));
+  table.add ("Wk");
+  table.add ("Date");
+  table.add ("Day");
+  table.add ("Start", false);
+  table.add ("End", false);
+  table.add ("Time", false);
+  table.add ("Total", false);
+
+  // Each day is rendered separately.
+  time_t grand_total = 0;
+  Datetime previous;
+  for (Datetime day = _range.start; day < _range.end; day++)
+  {
+    auto day_range = getFullDay (day);
+    time_t daily_total = 0;
+
+    int row = -1;
+    for (auto &gap: subset (day_range, _intervals))
+    {
+      row = table.addRow ();
+
+      if (day != previous)
+      {
+        table.set (row, 0, format ("W{1}", day.week ()));
+        table.set (row, 1, day.toString ("Y-M-D"));
+        table.set (row, 2, Datetime::dayNameShort (day.dayOfWeek ()));
+        previous = day;
+      }
+
+      // Intersect track with day.
+      auto today = day_range.intersect (gap);
+      if (gap.is_open ())
+      {
+        today.end = Datetime ();
+      }
+
+      table.set (row, 3, today.start.toString ("h:N:S"));
+      table.set (row, 4, (gap.is_open () ? "-" : today.end.toString ("h:N:S")));
+      table.set (row, 5, Duration (today.total ()).formatHours ());
+
+      daily_total += today.total ();
+    }
+
+    if (row != -1)
+    {
+      table.set (row, 6, Duration (daily_total).formatHours ());
+    }
+
+    grand_total += daily_total;
+  }
+
+  // Add the total.
+  table.set (table.addRow (), 6, " ", Color ("underline"));
+  table.set (table.addRow (), 6, Duration (grand_total).formatHours ());
+
+  return table;
+}
diff --git a/src/GapsTable.h b/src/GapsTable.h
new file mode 100644
--- /dev/null
+++ b/src/GapsTable.h
@@ -0,0 +1,54 @@
+//////////////////////////////////////////////////////////////////////////////
+//
+//  Copyright 2023, Gothenburg Bit Factory.
+//
+//  Permission is hereby granted, free of charge, to any person obtaining a copy
+//  of this software and associated documentation files (the "Software"), to deal
+//  in the Software without restriction, including without limitation the rights
+//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+//  copies of the Software, and to permit persons to whom the Software is
+//  furnished to do so, subject to the following conditions:
+//
+//  The above copyright notice and this permission notice shall be included
+//  in all copies or substantial portions of the Software.
+//
+//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+//  OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+//  THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+//  SOFTWARE.
+//
+//  https://www.opensource.org/licenses/mit-license.php
+//
+//////////////////////////////////////////////////////////////////////////////
+
+#ifndef INCLUDED_GAPSTABLE
+#define INCLUDED_GAPSTABLE
+
+#include <Interval.h>
+#include <Range.h>
+#include <Table.h>
+#include <vector>
+
+class GapsTable
+{
+  class Builder
+  {
+  public:
+    Builder& withRange (const Range &);
+    Builder& withIntervals (const std::vector <Range> &);
+
+    Table build ();
+
+  private:
+    std::vector <Range> _intervals;
+    Range _range;
+  };
+
+public:
+  static Builder builder ();
+};
+
+#endif //INCLUDED_GAPSTABLE
diff --git a/src/SummaryTable.cpp b/src/SummaryTable.cpp
new file mode 100644
--- /dev/null
+++ b/src/SummaryTable.cpp
@@ -0,0 +1,271 @@
+////////////////////////////////////////////////////////////////////////////////
+//
+//  Copyright 2023, Gothenburg Bit Factory.
+//
+//  Permission is hereby granted, free of charge, to any person obtaining a copy
+//  of this software and associated documentation files (the "Software"), to deal
+//  in the Software without restriction, including without limitation the rights
+//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+//  copies of the Software, and to permit persons to whom the Software is
+//  furnished to do so, subject to the following conditions:
+//
+//  The above copyright notice and this permission notice shall be included
+//  in all copies or substantial portions of the Software.
+//
+//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+//  OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+//  THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+//  SOFTWARE.
+//
+//  https://www.opensource.org/licenses/mit-license.php
+//
+////////////////////////////////////////////////////////////////////////////////
+
+#include <Datetime.h>
+#include <SummaryTable.h>
+#include <Table.h>
+#include <format.h>
+#include <timew.h>
+#include <utf8.h>
+#include <utility>
+
+////////////////////////////////////////////////////////////////////////////////
+SummaryTable::Builder SummaryTable::builder ()
+{
+  return {};
+}
+
+////////////////////////////////////////////////////////////////////////////////
+SummaryTable::Builder& SummaryTable::Builder::withWeekFormat (const std::string& format)
+{
+  _week_fmt = format;
+  return *this;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+SummaryTable::Builder& SummaryTable::Builder::withDateFormat (const std::string& format)
+{
+  _date_fmt = format;
+  return *this;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+SummaryTable::Builder& SummaryTable::Builder::withTimeFormat (const std::string& format)
+{
+  _time_fmt = format;
+  return *this;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+SummaryTable::Builder& SummaryTable::Builder::withAnnotations (const bool show)
+{
+  _show_annotations = show;
+  return *this;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+SummaryTable::Builder& SummaryTable::Builder::withIds (bool show, Color color)
+{
+  _show_ids = show;
+  _color_id = color;
+  return *this;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+SummaryTable::Builder & SummaryTable::Builder::withTags (bool show, std::map <std::string, Color>& colors)
+{
+  _show_tags = show;
+  _color_tags = std::move (colors);
+  return *this;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+SummaryTable::Builder& SummaryTable::Builder::withWeekdays (const bool show)
+{
+  _show_weekdays = show;
+  return *this;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+SummaryTable::Builder& SummaryTable::Builder::withWeeks (const bool show)
+{
+  _show_weeks = show;
+  return *this;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+SummaryTable::Builder& SummaryTable::Builder::withRange (const Range& range)
+{
+  _range = range;
+  return *this;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+SummaryTable::Builder & SummaryTable::Builder::withIntervals (const std::vector<Interval>& tracked)
+{
+  _tracked = tracked;
+  return *this;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+Table SummaryTable::Builder::build ()
+{
+  const auto dates_col_offset = _show_weeks ? 1 : 0;
+  const auto weekdays_col_offset = dates_col_offset;
+  const auto ids_col_offset = weekdays_col_offset + (_show_weekdays ? 1: 0);
+  const auto tags_col_offset = ids_col_offset + (_show_ids ? 1 : 0);
+  const auto annotation_col_offset = tags_col_offset + (_show_tags ? 1 : 0);
+  const auto start_col_offset = annotation_col_offset + (_show_annotations ? 1 : 0);
+
+  const auto weeks_col_index = 0;
+  const auto dates_col_index = 0 + dates_col_offset;
+  const auto weekdays_col_index = 1 + weekdays_col_offset;
+  const auto ids_col_index = 1 + ids_col_offset;
+  const auto tags_col_index = 1 + tags_col_offset;
+  const auto annotation_col_index = 1 + annotation_col_offset;
+  const auto start_col_index = 1 + start_col_offset;
+  const auto end_col_index = 2 + start_col_offset;
+  const auto duration_col_index = 3 + start_col_offset;
+  const auto total_col_index = 4 + start_col_offset;
+
+  int terminalWidth = getTerminalWidth ();
+
+  Table table;
+  table.width (terminalWidth);
+  table.colorHeader (Color ("underline"));
+
+  if (_show_weeks)
+  {
+    table.add ("Wk");
+  }
+
+  table.add ("Date");
+
+  if (_show_weekdays)
+  {
+    table.add ("Day");
+  }
+
+  if (_show_ids)
+  {
+    table.add ("ID");
+  }
+
+  if (_show_tags)
+  {
+    table.add ("Tags");
+  }
+
+  if (_show_annotations)
+  {
+    table.add ("Annotation");
+  }
+
+  table.add ("Start", false);
+  table.add ("End", false);
+  table.add ("Time", false);
+  table.add ("Total", false);
+
+  // Each day is rendered separately.
+  time_t grand_total = 0;
+  Datetime previous;
+
+  auto days_start = _range.is_started () ? _range.start : _tracked.front ().start;
+  auto days_end   = _range.is_ended ()   ? _range.end   : _tracked.back ().end;
+
+  const auto now = Datetime ();
+
+  if (days_end == 0)
+  {
+    days_end = now;
+  }
+
+  for (Datetime day = days_start.startOfDay (); day < days_end; ++day)
+  {
+    auto day_range = getFullDay (day);
+    time_t daily_total = 0;
+
+    int row = -1;
+    for (auto& track : subset (day_range, _tracked))
+    {
+      // Make sure the track only represents one day.
+      if ((track.is_open () && day > now))
+      {
+        continue;
+      }
+
+      row = table.addRow ();
+
+      if (day != previous)
+      {
+        if (_show_weeks)
+        {
+          table.set (row, weeks_col_index, format (_week_fmt, day.week ()));
+        }
+
+        table.set (row, dates_col_index, day.toString (_date_fmt));
+
+        if (_show_weekdays)
+        {
+          table.set (row, weekdays_col_index, Datetime::dayNameShort (day.dayOfWeek ()));
+        }
+
+        previous = day;
+      }
+
+      // Intersect track with day.
+      auto today = day_range.intersect (track);
+
+      if (track.is_open () && track.start > now)
+      {
+        today.end = track.start;
+      }
+      else if (track.is_open () && day <= now && today.end > now)
+      {
+        today.end = now;
+      }
+
+      if (_show_ids)
+      {
+        table.set (row, ids_col_index, format ("@{1}", track.id), _color_id);
+      }
+
+      if (_show_tags)
+      {
+        auto tags_string = join (", ", track.tags ());
+        table.set (row, tags_col_index, tags_string, summaryIntervalColor (_color_tags, track.tags ()));
+      }
+
+      if (_show_annotations)
+      {
+        table.set (row, annotation_col_index, track.getAnnotation ());
+      }
+
+      const auto total = today.total ();
+
+      table.set (row, start_col_index, today.start.toString (_time_fmt));
+      table.set (row, end_col_index, (track.is_open () ? "-" : today.end.toString (_time_fmt)));
+      table.set (row, duration_col_index, Duration (total).formatHours ());
+
+      daily_total += total;
+    }
+
+    if (row != -1)
+    {
+      table.set (row, total_col_index, Duration (daily_total).formatHours ());
+    }
+
+    grand_total += daily_total;
+  }
+
+  // Add the total.
+  table.set (table.addRow (), total_col_index, " ", Color ("underline"));
+  table.set (table.addRow (), total_col_index, Duration (grand_total).formatHours ());
+
+  return table;
+}
+
+////////////////////////////////////////////////////////////////////////////////
diff --git a/src/SummaryTable.h b/src/SummaryTable.h
new file mode 100644
--- /dev/null
+++ b/src/SummaryTable.h
@@ -0,0 +1,77 @@
+//////////////////////////////////////////////////////////////////////////////
+//
+//  Copyright 2023, Gothenburg Bit Factory.
+//
+//  Permission is hereby granted, free of charge, to any person obtaining a copy
+//  of this software and associated documentation files (the "Software"), to deal
+//  in the Software without restriction, including without limitation the rights
+//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+//  copies of the Software, and to permit persons to whom the Software is
+//  furnished to do so, subject to the following conditions:
+//
+//  The above copyright notice and this permission notice shall be included
+//  in all copies or substantial portions of the Software.
+//
+//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+//  OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+//  THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+//  SOFTWARE.
+//
+//  https://www.opensource.org/licenses/mit-license.php
+//
+//////////////////////////////////////////////////////////////////////////////
+
+#ifndef INCLUDED_SUMMARYTABLE
+#define INCLUDED_SUMMARYTABLE
+
+#include <Color.h>
+#include <Interval.h>
+#include <Range.h>
+#include <Table.h>
+#include <map>
+
+class SummaryTable
+{
+  class Builder
+  {
+  public:
+    Builder& withWeekFormat (const std::string &);
+    Builder& withDateFormat (const std::string &);
+    Builder& withTimeFormat (const std::string &);
+
+    Builder& withAnnotations (bool);
+    Builder& withIds (bool, Color);
+    Builder& withTags (bool, std::map <std::string, Color>&);
+    Builder& withWeeks (bool);
+    Builder& withWeekdays (bool);
+
+    Builder& withRange (const Range &);
+    Builder& withIntervals (const std::vector <Interval>&);
+
+    Table build ();
+
+  private:
+    std::string _week_fmt;
+    std::string _date_fmt;
+    std::string _time_fmt;
+
+    bool _show_annotations;
+    bool _show_ids;
+    bool _show_tags;
+    bool _show_weekdays;
+    bool _show_weeks;
+
+    Range _range;
+    std::vector <Interval> _tracked;
+    Color _color_id;
+    std::map <std::string, Color> _color_tags;
+  };
+
+public:
+  static Builder builder ();
+};
+
+#endif // INCLUDED_SUMMARYTABLE
diff --git a/src/TagDescription.cpp b/src/TagDescription.cpp
new file mode 100644
--- /dev/null
+++ b/src/TagDescription.cpp
@@ -0,0 +1,35 @@
+//////////////////////////////////////////////////////////////////////////////
+//
+//  Copyright 2023, Gothenburg Bit Factory.
+//
+//  Permission is hereby granted, free of charge, to any person obtaining a copy
+//  of this software and associated documentation files (the "Software"), to deal
+//  in the Software without restriction, including without limitation the rights
+//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+//  copies of the Software, and to permit persons to whom the Software is
+//  furnished to do so, subject to the following conditions:
+//
+//  The above copyright notice and this permission notice shall be included
+//  in all copies or substantial portions of the Software.
+//
+//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+//  OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+//  THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+//  SOFTWARE.
+//
+//  https://www.opensource.org/licenses/mit-license.php
+//
+//////////////////////////////////////////////////////////////////////////////
+
+#include <Color.h>
+#include <TagDescription.h>
+#include <utility>
+
+TagDescription::TagDescription (std::string name, Color color, std::string description) :
+  name (std::move (name)),
+  color (color),
+  description (std::move (description))
+{}
diff --git a/src/TagDescription.h b/src/TagDescription.h
new file mode 100644
--- /dev/null
+++ b/src/TagDescription.h
@@ -0,0 +1,42 @@
+//////////////////////////////////////////////////////////////////////////////
+//
+//  Copyright 2023, Gothenburg Bit Factory.
+//
+//  Permission is hereby granted, free of charge, to any person obtaining a copy
+//  of this software and associated documentation files (the "Software"), to deal
+//  in the Software without restriction, including without limitation the rights
+//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+//  copies of the Software, and to permit persons to whom the Software is
+//  furnished to do so, subject to the following conditions:
+//
+//  The above copyright notice and this permission notice shall be included
+//  in all copies or substantial portions of the Software.
+//
+//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+//  OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+//  THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+//  SOFTWARE.
+//
+//  https://www.opensource.org/licenses/mit-license.php
+//
+//////////////////////////////////////////////////////////////////////////////
+
+#ifndef INCLUDED_TAGDESCRIPTION
+#define INCLUDED_TAGDESCRIPTION
+
+#include <string>
+
+class TagDescription
+{
+public:
+  TagDescription (std::string , Color, std::string );
+
+  std::string name;
+  Color color;
+  std::string description;
+};
+
+#endif //INCLUDED_TAGDESCRIPTION
diff --git a/src/TagsTable.cpp b/src/TagsTable.cpp
new file mode 100644
--- /dev/null
+++ b/src/TagsTable.cpp
@@ -0,0 +1,64 @@
+////////////////////////////////////////////////////////////////////////////////
+//
+//  Copyright 2023, Gothenburg Bit Factory.
+//
+//  Permission is hereby granted, free of charge, to any person obtaining a copy
+//  of this software and associated documentation files (the "Software"), to deal
+//  in the Software without restriction, including without limitation the rights
+//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+//  copies of the Software, and to permit persons to whom the Software is
+//  furnished to do so, subject to the following conditions:
+//
+//  The above copyright notice and this permission notice shall be included
+//  in all copies or substantial portions of the Software.
+//
+//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+//  OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+//  THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+//  SOFTWARE.
+//
+//  https://www.opensource.org/licenses/mit-license.php
+//
+////////////////////////////////////////////////////////////////////////////////
+
+#include <TagsTable.h>
+#include <timew.h>
+
+////////////////////////////////////////////////////////////////////////////////
+TagsTable::Builder TagsTable::builder ()
+{
+  return {};
+}
+
+////////////////////////////////////////////////////////////////////////////////
+TagsTable::Builder& TagsTable::Builder::withTagDescriptions (std::vector <TagDescription>& tagDescriptions)
+{
+  _tagDescriptions = tagDescriptions;
+  return *this;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+Table TagsTable::Builder::build ()
+{
+  int terminalWidth = getTerminalWidth ();
+
+  Table table;
+  table.width (terminalWidth);
+  table.colorHeader (Color ("underline"));
+  table.add ("Tag");
+  table.add ("Description");
+
+  for (const auto& tagDescription : _tagDescriptions)
+  {
+    auto row = table.addRow ();
+    table.set (row, 0, tagDescription.name, tagDescription.color);
+    table.set (row, 1, tagDescription.description);
+  }
+
+  return table;
+}
+
+////////////////////////////////////////////////////////////////////////////////
diff --git a/src/TagsTable.h b/src/TagsTable.h
new file mode 100644
--- /dev/null
+++ b/src/TagsTable.h
@@ -0,0 +1,51 @@
+//////////////////////////////////////////////////////////////////////////////
+//
+//  Copyright 2023, Gothenburg Bit Factory.
+//
+//  Permission is hereby granted, free of charge, to any person obtaining a copy
+//  of this software and associated documentation files (the "Software"), to deal
+//  in the Software without restriction, including without limitation the rights
+//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+//  copies of the Software, and to permit persons to whom the Software is
+//  furnished to do so, subject to the following conditions:
+//
+//  The above copyright notice and this permission notice shall be included
+//  in all copies or substantial portions of the Software.
+//
+//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+//  OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+//  THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+//  SOFTWARE.
+//
+//  https://www.opensource.org/licenses/mit-license.php
+//
+//////////////////////////////////////////////////////////////////////////////
+
+#ifndef INCLUDED_TAGSTABLEBUILDER
+#define INCLUDED_TAGSTABLEBUILDER
+
+#include <Table.h>
+#include <TagDescription.h>
+#include <vector>
+
+class TagsTable
+{
+  class Builder
+  {
+  public:
+    Builder& withTagDescriptions (std::vector <TagDescription>&);
+
+    Table build ();
+
+  private:
+    std::vector <TagDescription> _tagDescriptions {};
+  };
+
+public:
+  static Builder builder ();
+};
+
+#endif //INCLUDED_TAGSTABLEBUILDER
diff --git a/src/commands/CmdExtensions.cpp b/src/commands/CmdExtensions.cpp
--- a/src/commands/CmdExtensions.cpp
+++ b/src/commands/CmdExtensions.cpp
@@ -1,6 +1,6 @@
 ////////////////////////////////////////////////////////////////////////////////
 //
-// Copyright 2016 - 2019, 2022, Thomas Lauf, Paul Beckingham, Federico Hernandez.
+// Copyright 2016 - 2019, 2022 - 2023, Gothenburg Bit Factory.
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
 // of this software and associated documentation files (the "Software"), to deal
@@ -24,7 +24,7 @@
 //
 ////////////////////////////////////////////////////////////////////////////////
 
-#include <Table.h>
+#include <ExtensionsTable.h>
 #include <commands.h>
 #include <iostream>
 #include <paths.h>
@@ -33,30 +33,9 @@
 // Enumerate all extensions.
 int CmdExtensions (const Extensions& extensions)
 {
-  Table table;
-  table.width (1024);
-  table.colorHeader (Color ("underline"));
-  table.add ("Extension", true);
-  table.add ("Status", true);
-
-  for (auto& ext : extensions.all ())
-  {
-    File program (ext);
-
-    // Show program name.
-    auto row = table.addRow ();
-    table.set (row, 0, program.name ());
-
-    // Show extension status.
-    std::string perms;
-         if (! program.readable ())   perms = "Not readable";
-    else if (! program.executable ()) perms = "No executable";
-    else                              perms = "Active";
-
-    if (program.is_link ())           perms += " (link)";
-
-    table.set (row, 1, perms);
-  }
+  auto table = ExtensionsTable::builder()
+    .withExtensions (extensions)
+    .build ();
 
   Directory extDir (paths::extensionsDir ());
 
@@ -66,6 +45,7 @@ int CmdExtensions (const Extensions& extensions)
             << '\n'
             << table.render ()
             << '\n';
+
   return 0;
 }
 
diff --git a/src/commands/CmdGaps.cpp b/src/commands/CmdGaps.cpp
--- a/src/commands/CmdGaps.cpp
+++ b/src/commands/CmdGaps.cpp
@@ -1,6 +1,6 @@
 ////////////////////////////////////////////////////////////////////////////////
 //
-// Copyright 2016 - 2023, Thomas Lauf, Paul Beckingham, Federico Hernandez.
+// Copyright 2016 - 2023, Gothenburg Bit Factory.
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
 // of this software and associated documentation files (the "Software"), to deal
@@ -25,9 +25,8 @@
 ////////////////////////////////////////////////////////////////////////////////
 
 #include <Duration.h>
-#include <Table.h>
+#include <GapsTable.h>
 #include <commands.h>
-#include <format.h>
 #include <iostream>
 #include <timew.h>
 
@@ -62,71 +61,24 @@ int CmdGaps (
     untracked = getUntracked (database, rules, filter);
   }
 
-  Table table;
-  table.width (1024);
-  table.colorHeader (Color ("underline"));
-  table.add ("Wk");
-  table.add ("Date");
-  table.add ("Day");
-  table.add ("Start", false);
-  table.add ("End", false);
-  table.add ("Time", false);
-  table.add ("Total", false);
-
-  // Each day is rendered separately.
-  time_t grand_total = 0;
-  Datetime previous;
-  for (Datetime day = range.start; day < range.end; day++)
+  if (untracked.empty ())
   {
-    auto day_range = getFullDay (day);
-    time_t daily_total = 0;
-
-    int row = -1;
-    for (auto& gap : subset (day_range, untracked))
+    if (verbose)
     {
-      row = table.addRow ();
-
-      if (day != previous)
-      {
-        table.set (row, 0, format ("W{1}", day.week ()));
-        table.set (row, 1, day.toString ("Y-M-D"));
-        table.set (row, 2, Datetime::dayNameShort (day.dayOfWeek ()));
-        previous = day;
-      }
-
-      // Intersect track with day.
-      auto today = day_range.intersect (gap);
-      if (gap.is_open ())
-        today.end = Datetime ();
-
-      table.set (row, 3, today.start.toString ("h:N:S"));
-      table.set (row, 4, (gap.is_open () ? "-" : today.end.toString ("h:N:S")));
-      table.set (row, 5, Duration (today.total ()).formatHours ());
-
-      daily_total += today.total ();
+      std::cout << "No gaps found.\n";
     }
-
-    if (row != -1)
-      table.set (row, 6, Duration (daily_total).formatHours ());
-
-    grand_total += daily_total;
   }
-
-  // Add the total.
-  table.set (table.addRow (), 6, " ", Color ("underline"));
-  table.set (table.addRow (), 6, Duration (grand_total).formatHours ());
-
-  if (table.rows () > 2)
+  else
   {
+    auto table = GapsTable::builder ()
+      .withRange (range)
+      .withIntervals (untracked)
+      .build ();
+
     std::cout << '\n'
               << table.render ()
               << '\n';
   }
-  else
-  {
-    if (verbose)
-      std::cout << "No gaps found.\n";
-  }
 
   return 0;
 }
diff --git a/src/commands/CmdSummary.cpp b/src/commands/CmdSummary.cpp
--- a/src/commands/CmdSummary.cpp
+++ b/src/commands/CmdSummary.cpp
@@ -24,10 +24,10 @@
 //
 ////////////////////////////////////////////////////////////////////////////////
 
-#include <Duration.h>
 #include <IntervalFilterAllInRange.h>
 #include <IntervalFilterAllWithTags.h>
 #include <IntervalFilterAndGroup.h>
+#include <SummaryTable.h>
 #include <Table.h>
 #include <commands.h>
 #include <format.h>
@@ -92,10 +92,7 @@ int CmdSummary (
 
   // Map tags to colors.
   Color colorID (rules.getBoolean ("color") ? rules.get ("theme.colors.ids") : "");
-
-  const auto week_fmt = "W{1}";
-  const auto date_fmt = "Y-M-D";
-  const auto time_fmt = "h:N:S";
+  auto tagColorMap = createTagColorMap (rules, tracked);
 
   const auto show_weeks = rules.getBoolean ("reports.summary.weeks", true);
   const auto show_weekdays = rules.getBoolean ("reports.summary.weekdays", true);
@@ -104,162 +101,18 @@ int CmdSummary (
   const auto show_annotations = cli.getComplementaryHint ("annotations", rules.getBoolean ("reports.summary.annotations"));
   const auto show_holidays = cli.getComplementaryHint ("holidays", rules.getBoolean ("reports.summary.holidays"));
 
-  const auto dates_col_offset = show_weeks ? 1 : 0;
-  const auto weekdays_col_offset = dates_col_offset;
-  const auto ids_col_offset = weekdays_col_offset + (show_weekdays ? 1: 0);
-  const auto tags_col_offset = ids_col_offset + (show_ids ? 1 : 0);
-  const auto annotation_col_offset = tags_col_offset + (show_tags ? 1 : 0);
-  const auto start_col_offset = annotation_col_offset + (show_annotations ? 1 : 0);
-
-  const auto weeks_col_index = 0;
-  const auto dates_col_index = 0 + dates_col_offset;
-  const auto weekdays_col_index = 1 + weekdays_col_offset;
-  const auto ids_col_index = 1 + ids_col_offset;
-  const auto tags_col_index = 1 + tags_col_offset;
-  const auto annotation_col_index = 1 + annotation_col_offset;
-  const auto start_col_index = 1 + start_col_offset;
-  const auto end_col_index = 2 + start_col_offset;
-  const auto duration_col_index = 3 + start_col_offset;
-  const auto total_col_index = 4 + start_col_offset;
-
-  Table table;
-  table.width (1024);
-  table.colorHeader (Color ("underline"));
-
-  if (show_weeks)
-  {
-    table.add ("Wk");
-  }
-
-  table.add ("Date");
-
-  if (show_weekdays)
-  {
-    table.add ("Day");
-  }
-
-  if (show_ids)
-  {
-    table.add ("ID");
-  }
-
-  if (show_tags)
-  {
-    table.add ("Tags");
-  }
-
-  if (show_annotations)
-  {
-    table.add ("Annotation");
-  }
-
-  table.add ("Start", false);
-  table.add ("End", false);
-  table.add ("Time", false);
-  table.add ("Total", false);
-
-  // Each day is rendered separately.
-  time_t grand_total = 0;
-  Datetime previous;
-
-  auto days_start = range.is_started() ? range.start : tracked.front ().start;
-  auto days_end   = range.is_ended()   ? range.end   : tracked.back ().end;
-
-  const auto now = Datetime ();
-
-  if (days_end == 0)
-  {
-    days_end = now;
-  }
-
-  for (Datetime day = days_start.startOfDay (); day < days_end; ++day)
-  {
-    auto day_range = getFullDay (day);
-    time_t daily_total = 0;
-
-    int row = -1;
-    for (auto& track : subset (day_range, tracked))
-    {
-      // Make sure the track only represents one day.
-      if ((track.is_open () && day > now))
-      {
-        continue;
-      }
-
-      row = table.addRow ();
-
-      if (day != previous)
-      {
-        if (show_weeks)
-        {
-          table.set (row, weeks_col_index, format (week_fmt, day.week ()));
-        }
-
-        table.set (row, dates_col_index, day.toString (date_fmt));
-
-        if (show_weekdays)
-        {
-          table.set (row, weekdays_col_index, Datetime::dayNameShort (day.dayOfWeek ()));
-        }
-
-        previous = day;
-      }
-
-      // Intersect track with day.
-      auto today = day_range.intersect (track);
-
-      if (track.is_open() && track.start > now)
-      {
-        today.end = track.start;
-      }
-      else if (track.is_open () && day <= now && today.end > now)
-      {
-        today.end = now;
-      }
-
-      if (show_ids)
-      {
-        table.set (row, ids_col_index, format ("@{1}", track.id), colorID);
-      }
-
-      if (show_tags)
-      {
-        std::string tags_string = join (", ", track.tags ());
-        table.set (row, tags_col_index, tags_string, summaryIntervalColor (rules, track.tags ()));
-      }
-
-      if (show_annotations)
-      {
-        auto annotation = track.getAnnotation ();
-
-        if (utf8_length (annotation) > 15)
-        {
-          annotation = utf8_substr (annotation, 0, 12) + "...";
-        }
-
-        table.set (row, annotation_col_index, annotation);
-      }
-
-      const auto total = today.total ();
-
-      table.set (row, start_col_index, today.start.toString (time_fmt));
-      table.set (row, end_col_index, (track.is_open () ? "-" : today.end.toString (time_fmt)));
-      table.set (row, duration_col_index, Duration (total).formatHours ());
-
-      daily_total += total;
-    }
-
-    if (row != -1)
-    {
-      table.set (row, total_col_index, Duration (daily_total).formatHours ());
-    }
-
-    grand_total += daily_total;
-  }
-
-  // Add the total.
-  table.set (table.addRow (), total_col_index, " ", Color ("underline"));
-  table.set (table.addRow (), total_col_index, Duration (grand_total).formatHours ());
+  auto table = SummaryTable::builder ()
+    .withWeekFormat ("W{1}")
+    .withDateFormat ("Y-M-D")
+    .withTimeFormat ("h:N:S")
+    .withWeeks (show_weeks)
+    .withWeekdays (show_weekdays)
+    .withIds (show_ids, colorID)
+    .withTags (show_tags, tagColorMap)
+    .withAnnotations (show_annotations)
+    .withRange (range)
+    .withIntervals (tracked)
+    .build ();
 
   std::cout << '\n'
             << table.render ()
@@ -270,11 +123,11 @@ int CmdSummary (
 }
 
 ////////////////////////////////////////////////////////////////////////////////
-std::string renderHolidays (const std::map<Datetime, std::string> &holidays)
+std::string renderHolidays (const std::map<Datetime, std::string>& holidays)
 {
   std::stringstream out;
 
-  for (auto &entry : holidays)
+  for (auto& entry : holidays)
   {
     out << entry.first.toString ("Y-M-D")
         << " "
diff --git a/src/commands/CmdTags.cpp b/src/commands/CmdTags.cpp
--- a/src/commands/CmdTags.cpp
+++ b/src/commands/CmdTags.cpp
@@ -1,6 +1,6 @@
 ////////////////////////////////////////////////////////////////////////////////
 //
-// Copyright 2016 - 2023, Thomas Lauf, Paul Beckingham, Federico Hernandez.
+// Copyright 2016 - 2023, Gothenburg Bit Factory.
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
 // of this software and associated documentation files (the "Software"), to deal
@@ -29,6 +29,8 @@
 #include <IntervalFilterAllWithTags.h>
 #include <IntervalFilterAndGroup.h>
 #include <Table.h>
+#include <TagDescription.h>
+#include <TagsTable.h>
 #include <commands.h>
 #include <iostream>
 #include <set>
@@ -51,37 +53,39 @@ int CmdTags (
   std::set <std::string> tags;
 
   for (const auto& interval : getTracked (database, rules, filtering))
-    for (auto& tag : interval.tags ())
+  {
+    for (const auto& tag : interval.tags ())
+    {
       tags.insert (tag);
+    }
+  }
 
   // Shows all tags.
-  if (! tags.empty ())
+  if (tags.empty ())
   {
-    Table table;
-    table.width (1024);
-    table.colorHeader (Color ("underline"));
-    table.add ("Tag");
-    table.add ("Description");
-    // TODO Show all tag metadata.
-
-    for (auto& tag : tags)
+    if (verbose)
     {
-      auto row = table.addRow ();
-      table.set (row, 0, tag, tagColor (rules, tag));
+      std::cout << "No data found.\n";
+    }
+  }
+  else
+  {
+    std::vector <TagDescription> tagDescriptions;
 
+    for (const auto& tag: tags)
+    {
       auto name = std::string ("tags.") + tag + ".description";
-      table.set (row, 1, rules.has (name) ? rules.get (name) : "-");
+      tagDescriptions.emplace_back (tag, tagColor (rules, tag), rules.has (name) ? rules.get (name) : "-");
     }
 
+    auto table = TagsTable::builder()
+      .withTagDescriptions (tagDescriptions)
+      .build ();
+
     std::cout << '\n'
               << table.render ()
               << '\n';
   }
-  else
-  {
-    if (verbose)
-      std::cout << "No data found.\n";
-  }
 
   return 0;
 }
diff --git a/src/helper.cpp b/src/helper.cpp
--- a/src/helper.cpp
+++ b/src/helper.cpp
@@ -27,11 +27,15 @@
 #include <Datetime.h>
 #include <Duration.h>
 #include <IntervalFactory.h>
+#include <Table.h>
 #include <format.h>
 #include <iomanip>
 #include <map>
 #include <sstream>
+#include <string>
+#include <sys/ioctl.h>
 #include <timew.h>
+#include <unistd.h>
 #include <vector>
 
 ////////////////////////////////////////////////////////////////////////////////
@@ -50,6 +54,21 @@ Color summaryIntervalColor (
   return c;
 }
 
+////////////////////////////////////////////////////////////////////////////////
+Color summaryIntervalColor (
+  std::map <std::string, Color>& tagColors,
+  const std::set <std::string>& tags)
+{
+  Color c;
+
+  for (const auto& tag : tags)
+  {
+    c.blend (tagColors[tag]);
+  }
+
+  return c;
+}
+
 ////////////////////////////////////////////////////////////////////////////////
 // Select a color to represent the interval on a chart.
 Color chartIntervalColor (
@@ -410,6 +429,30 @@ std::map <std::string, Color> createTagColorMap (
   return mapping;
 }
 
+////////////////////////////////////////////////////////////////////////////////
+std::map <std::string, Color> createTagColorMap (const Rules& rules, const std::vector <Interval>& intervals)
+{
+  std::set <std::string> tags;
+
+  for (const auto& interval : intervals)
+  {
+    tags.insert (interval.tags ().begin (), interval.tags ().end ());
+  }
+
+  std::map <std::string, Color> mapping;
+
+  for (const auto& tag : tags)
+  {
+    std::string key = "tags." + tag + ".color";
+    if (rules.has (key))
+    {
+      mapping[tag] = Color (rules.get (key));
+    }
+  }
+
+  return mapping;
+}
+
 ////////////////////////////////////////////////////////////////////////////////
 int quantizeToNMinutes (const int minutes, const int N)
 {
@@ -472,3 +515,20 @@ std::string minimalDelta (const Datetime& left, const Datetime& right)
 }
 
 ////////////////////////////////////////////////////////////////////////////////
+int getTerminalWidth ()
+{
+  int terminalWidth;
+#ifdef TIOCGSIZE
+  struct ttysize ts{};
+  ioctl (STDIN_FILENO, TIOCGSIZE, &ts);
+  terminalWidth = ts.ts_cols;
+#elif defined(TIOCGWINSZ)
+  struct winsize ts {};
+  ioctl(STDIN_FILENO, TIOCGWINSZ, &ts);
+  terminalWidth = ts.ws_col;
+#endif
+
+  return terminalWidth > 0 ? terminalWidth : 80;
+}
+
+////////////////////////////////////////////////////////////////////////////////
diff --git a/src/timew.h b/src/timew.h
--- a/src/timew.h
+++ b/src/timew.h
@@ -69,6 +69,7 @@ int dispatchCommand (const CLI&, Database&, Journal&, Rules&, const Extensions&)
 
 // helper.cpp
 Color summaryIntervalColor (const Rules&, const std::set <std::string>&);
+Color summaryIntervalColor (std::map <std::string, Color>&, const std::set <std::string>&);
 Color chartIntervalColor (const std::set <std::string>&, const std::map <std::string, Color>&);
 Color tagColor (const Rules&, const std::string&);
 std::string intervalSummarize (const Rules&, const Interval&);
@@ -76,10 +77,12 @@ bool expandIntervalHint (const std::string&, Range&);
 std::string jsonFromIntervals (const std::vector <Interval>&);
 Palette createPalette (const Rules&);
 std::map <std::string, Color> createTagColorMap (const Rules&, Palette&, const std::vector <Interval>&);
+std::map <std::string, Color> createTagColorMap (const Rules& rules, const std::vector <Interval>& intervals);
 int quantizeToNMinutes (int, int);
 
 bool findHint (const CLI&, const std::string&);
 std::string minimalDelta (const Datetime&, const Datetime&);
+int getTerminalWidth () ;
 
 // log.cpp
 void enableDebugMode (bool);


Test patch:
diff --git a/test/summary.t b/test/summary.t
--- a/test/summary.t
+++ b/test/summary.t
@@ -352,15 +352,14 @@ W\d{1,2} \d{4}-\d{2}-\d{2} .{3}       ?0:00:00 0:00:00 0:00:00 0:00:00
 [ ]+0:00:00
 """)
 
-    def test_multibyte_char_annotation_truncated(self):
-        """Summary correctly truncates long annotation containing multibyte characters"""
-        # Using a blue heart emoji as an example of a multibyte (4 bytes in
-        # this case) character.
-        long_enough_annotation = "a" + "\N{blue heart}" * 20
-        self.t("track FOO sod - sod")
+    def test_multibyte_char_annotation_wrapped(self):
+        """Summary correctly wraps long annotation containing multibyte characters"""
+        # Using a blue heart emoji as an example of a multibyte (4 bytes in this case) character.
+        long_enough_annotation = "'" + ("a" + "💙" * 5 + " ") * 5 + "'"
+        self.t("track FOO sod - sond")
         self.t("anno @1 " + long_enough_annotation)
         code, out, err = self.t("summary :anno")
-        self.assertIn("a" + "\N{blue heart}" * 11 + "...", out)
+        self.assertRegex(out, r"(a💙{5} )\s{0,11}0:00:00")
 
 
 if __name__ == "__main__":