Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add calendar heatmap display format #1759

Merged
merged 21 commits into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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