Skip to content

Commit

Permalink
Add calendar heatmap display format (#1759)
Browse files Browse the repository at this point in the history
* Add calendar heatmap exporter

Fix #743

* Lint fixes

* More lint fixes

* Surface total number of entries per month in heatmap

* Refactoring

* More refactoring

* Resolve last lint error

* Unbump version

* Add calendar export test scaffolding

* WIP: Test debugging + scaffolding

* Remove broken tests

* Remove args from .vscode/launch.json

* Discard changes to tests/bdd/features/format.feature

* Remove extraneous vscode files

* move NestedDict to utils file

* run formatter

* fix import error

* Address lints

---------

Co-authored-by: Micah Jerome Ellison <micah.jerome.ellison@gmail.com>
Co-authored-by: Jonathan Wren <jonathan@nowandwren.com>
  • Loading branch information
3 people authored Oct 2, 2024
1 parent 2f0c5d2 commit a8bd0bc
Show file tree
Hide file tree
Showing 4 changed files with 155 additions and 8 deletions.
6 changes: 4 additions & 2 deletions jrnl/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from typing import Type

from jrnl.plugins.calendar_heatmap_exporter import CalendarHeatmapExporter
from jrnl.plugins.dates_exporter import DatesExporter
from jrnl.plugins.fancy_exporter import FancyExporter
from jrnl.plugins.jrnl_importer import JRNLImporter
Expand All @@ -14,14 +15,15 @@
from jrnl.plugins.yaml_exporter import YAMLExporter

__exporters = [
CalendarHeatmapExporter,
DatesExporter,
FancyExporter,
JSONExporter,
MarkdownExporter,
TagExporter,
DatesExporter,
TextExporter,
XMLExporter,
YAMLExporter,
FancyExporter,
]
__importers = [JRNLImporter]

Expand Down
117 changes: 117 additions & 0 deletions jrnl/plugins/calendar_heatmap_exporter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Copyright © 2012-2023 jrnl contributors
# License: https://www.gnu.org/licenses/gpl-3.0.html

import calendar
from datetime import datetime
from typing import TYPE_CHECKING

from rich import box
from rich.align import Align
from rich.columns import Columns
from rich.console import Console
from rich.table import Table
from rich.text import Text

from jrnl.plugins.text_exporter import TextExporter
from jrnl.plugins.util import get_journal_frequency_nested

if TYPE_CHECKING:
from jrnl.journals import Entry
from jrnl.journals import Journal
from jrnl.plugins.util import NestedDict


class CalendarHeatmapExporter(TextExporter):
"""This Exporter displays a calendar heatmap of the journaling frequency."""

names = ["calendar", "heatmap"]
extension = "cal"

@classmethod
def export_entry(cls, entry: "Entry"):
raise NotImplementedError

@classmethod
def print_calendar_heatmap(cls, journal_frequency: "NestedDict") -> str:
"""Returns a string representation of the calendar heatmap."""
console = Console()
cal = calendar.Calendar()
curr_year = datetime.now().year
curr_month = datetime.now().month
curr_day = datetime.now().day
hit_first_entry = False
with console.capture() as capture:
for year, month_journaling_freq in journal_frequency.items():
year_calendar = []
for month in range(1, 13):
if month > curr_month and year == curr_year:
break

entries_this_month = sum(month_journaling_freq[month].values())
if not hit_first_entry and entries_this_month > 0:
hit_first_entry = True

if entries_this_month == 0 and not hit_first_entry:
continue
elif entries_this_month == 0:
entry_msg = "No entries"
elif entries_this_month == 1:
entry_msg = "1 entry"
else:
entry_msg = f"{entries_this_month} entries"
table = Table(
title=f"{calendar.month_name[month]} {year} ({entry_msg})",
title_style="bold green",
box=box.SIMPLE_HEAVY,
padding=0,
)

for week_day in cal.iterweekdays():
table.add_column(
"{:.3}".format(calendar.day_name[week_day]), justify="right"
)

month_days = cal.monthdayscalendar(year, month)
for weekdays in month_days:
days = []
for _, day in enumerate(weekdays):
if day == 0: # Not a part of this month, just filler.
day_label = Text(str(day or ""), style="white")
elif (
day > curr_day
and month == curr_month
and year == curr_year
):
break
else:
journal_frequency_for_day = (
month_journaling_freq[month][day] or 0
)
day = str(day)
# TODO: Make colors configurable?
if journal_frequency_for_day == 0:
day_label = Text(day, style="red on black")
elif journal_frequency_for_day == 1:
day_label = Text(day, style="black on yellow")
elif journal_frequency_for_day == 2:
day_label = Text(day, style="black on green")
else:
day_label = Text(day, style="black on white")

days.append(day_label)
table.add_row(*days)

year_calendar.append(Align.center(table))

# Print year header line
console.rule(str(year))
console.print()
# Print calendar
console.print(Columns(year_calendar, padding=1, expand=True))
return capture.get()

@classmethod
def export_journal(cls, journal: "Journal"):
"""Returns dates and their frequencies for an entire journal."""
journal_entry_date_frequency = get_journal_frequency_nested(journal)
return cls.print_calendar_heatmap(journal_entry_date_frequency)
8 changes: 2 additions & 6 deletions jrnl/plugins/dates_exporter.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Copyright © 2012-2023 jrnl contributors
# License: https://www.gnu.org/licenses/gpl-3.0.html

from collections import Counter
from typing import TYPE_CHECKING

from jrnl.plugins.text_exporter import TextExporter
from jrnl.plugins.util import get_journal_frequency_one_level

if TYPE_CHECKING:
from jrnl.journals import Entry
Expand All @@ -24,10 +24,6 @@ def export_entry(cls, entry: "Entry"):
@classmethod
def export_journal(cls, journal: "Journal") -> str:
"""Returns dates and their frequencies for an entire journal."""
date_counts = Counter()
for entry in journal.entries:
# entry.date.date() gets date without time
date = str(entry.date.date())
date_counts[date] += 1
date_counts = get_journal_frequency_one_level(journal)
result = "\n".join(f"{date}, {count}" for date, count in date_counts.items())
return result
32 changes: 32 additions & 0 deletions jrnl/plugins/util.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
# Copyright © 2012-2023 jrnl contributors
# License: https://www.gnu.org/licenses/gpl-3.0.html

from collections import Counter
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from jrnl.journals import Journal


class NestedDict(dict):
"""https://stackoverflow.com/a/74873621/8740440"""

def __missing__(self, x):
self[x] = NestedDict()
return self[x]


def get_tags_count(journal: "Journal") -> set[tuple[int, str]]:
"""Returns a set of tuples (count, tag) for all tags present in the journal."""
# Astute reader: should the following line leave you as puzzled as me the first time
Expand All @@ -29,3 +38,26 @@ def oxford_list(lst: list) -> str:
return lst[0] + " or " + lst[1]
else:
return ", ".join(lst[:-1]) + ", or " + lst[-1]


def get_journal_frequency_nested(journal: "Journal") -> NestedDict:
"""Returns a NestedDict of the form {year: {month: {day: count}}}"""
journal_frequency = NestedDict()
for entry in journal.entries:
date = entry.date.date()
if date.day in journal_frequency[date.year][date.month]:
journal_frequency[date.year][date.month][date.day] += 1
else:
journal_frequency[date.year][date.month][date.day] = 1

return journal_frequency


def get_journal_frequency_one_level(journal: "Journal") -> Counter:
"""Returns a Counter of the form {date (YYYY-MM-DD): count}"""
date_counts = Counter()
for entry in journal.entries:
# entry.date.date() gets date without time
date = str(entry.date.date())
date_counts[date] += 1
return date_counts

0 comments on commit a8bd0bc

Please sign in to comment.