Skip to content
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,13 @@ focus report today
focus report week
focus report month
focus report custom 14 # last 14 days
focus report custom 14 > report.md
```

Total time, a per-project breakdown, and every session with its notes. Plain facts about where your attention went. No score attached.

Reports come out as markdown on stdout: a period line, a project table, then each session as its own section. Notes are printed exactly as written, so if you write markdown in a note it stays markdown in the report β€” redirect it to a file and it's ready to share.

### The nudge

Nudging is the external clock. It's a cron job that fires every few minutes and tells you, plainly:
Expand Down
8 changes: 8 additions & 0 deletions docs/help/report.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,11 @@ Plain facts β€” no score attached.
month last 30 days custom last <days>

Duration-only sessions are included by their date.

Output is markdown on stdout β€” a period line, a project table, then every
session as its own section. Redirect it to keep one:

focus report custom 14 > report.md

Notes are printed exactly as you wrote them, so markdown in a note stays
markdown in the report.
85 changes: 68 additions & 17 deletions lib/report.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,19 @@ wants_help "$@" && show_help report

db_ensure

# Output is markdown, on stdout β€” `focus report custom 14 > report.md`.
#
# Notes are hand-written markdown: bullets, **bold**, > blockquotes. The old
# layout indented every note line under a πŸ“ prefix, which turned all of it
# into code blocks and meant a shareable report had to be reassembled by hand.
# Nothing here reformats a note; it is emitted verbatim and the structure
# survives.

_report() {
local label="$1" start="$2" end="$3"

echo "πŸ“Š $label"
printf '═%.0s' $(seq 1 "${#label}"); echo
echo "Period: $(ts_format "$start" "$DATE_FORMAT") β†’ $(ts_format "$end" "$DATE_FORMAT")"
echo "# Focus report"
echo "## $label"
echo ""

# No associative array: macOS ships bash 3.2 (no `declare -A` at all), so
Expand All @@ -27,27 +34,69 @@ _report() {
sessions=$(( sessions + 1 ))
done < <(list_sessions_in_range "$start" "$end")

echo "Total: $(fmt_duration $total) across $sessions session(s)"
local noun="sessions"
[[ $sessions -eq 1 ]] && noun="session"
echo "Period: $(ts_format "$start" "$DATE_FORMAT") β†’ $(ts_format "$end" "$DATE_FORMAT") Total: $(fmt_duration $total) across $sessions $noun"

# An empty period stops here: a header, the period line, and no rules
# trailing off the end of an otherwise blank document.
[[ $sessions -eq 0 ]] && return 0

echo ""
echo "---"
echo ""

# Project names cannot contain '|' β€” the sessions table CHECKs for it β€” so
# no table cell needs escaping.
local have_projects=0
while IFS='|' read -r p pdur pcnt; do
[[ $have_projects -eq 0 ]] && { echo "Projects:"; have_projects=1; }
printf " %-24s %s (%d session(s))\n" "$p" "$(fmt_duration "$pdur")" "$pcnt"
if [[ $have_projects -eq 0 ]]; then
echo "## Projects"
echo ""
echo "| Project | Time | Sessions |"
echo "|---|---:|---:|"
have_projects=1
fi
printf "| \`%s\` | %s | %s |\n" "$p" "$(fmt_duration "$pdur")" "$pcnt"
done < <(get_project_totals_in_range "$start" "$end")
[[ $have_projects -eq 1 ]] && echo ""
[[ $have_projects -eq 1 ]] && { echo ""; echo "---"; echo ""; }

echo "Sessions:"
local have_sessions=0 s e
while IFS='|' read -r id project start_t end_t dur notes duration_only session_date; do
if [[ $have_sessions -eq 0 ]]; then
echo "## Sessions"
echo ""
have_sessions=1
else
# Rules separate sessions from each other, so each one is written
# ahead of the session that follows it β€” that way the last session
# isn't left with a rule and a blank line trailing off the end.
echo ""
echo "---"
echo ""
fi

echo "### [$id] \`$project\`"
if [[ "$duration_only" == "1" ]]; then
echo " [$id] $project β€” $(fmt_duration "$dur") on $session_date (manual)"
echo "**$(fmt_duration "$dur") on $session_date (manual)**"
else
s=$(ts_format "$start_t" "$DATE_SHORT_FORMAT" 2>/dev/null)
e=$(ts_format "$end_t" "%H:%M" 2>/dev/null)
echo " [$id] $project β€” $s–$e ($(fmt_duration "$dur"))"
# Fall back to the stored string when it won't parse, the way
# `past list` does β€” under set -e a bare command substitution here
# would abort the whole report over one unreadable timestamp.
s=$(ts_format "$start_t" "$DATE_SHORT_FORMAT" 2>/dev/null || echo "$start_t")
e=$(ts_format "$end_t" "%H:%M" 2>/dev/null || echo "$end_t")
echo "**$s–$e Β· $(fmt_duration "$dur")**"
fi

if [[ -n "$notes" ]]; then
notes_block " πŸ“ " " " "$(notes_decode "$notes")"
# Verbatim β€” reformatting is what broke markdown before. The blank
# line separating the note from the heading above it is written
# here rather than unconditionally, so a session with no note
# doesn't leave two blank lines behind. notes_decode emits no
# trailing newline; the closing echo supplies it.
echo ""
notes_decode "$notes"
echo ""
fi
done < <(list_sessions_in_range "$start" "$end")
}
Expand All @@ -58,24 +107,26 @@ case "$period" in
today)
start=$(iso_days_ago 0)
end=$(now_iso)
_report "Today's Focus" "$start" "$end"
_report "Today" "$start" "$end"
;;
week)
start=$(iso_days_ago 7)
end=$(now_iso)
_report "This Week's Focus" "$start" "$end"
_report "This week" "$start" "$end"
;;
month)
start=$(iso_month_start)
end=$(now_iso)
_report "This Month's Focus" "$start" "$end"
_report "This month" "$start" "$end"
;;
custom)
days="${2:-7}"
[[ ! "$days" =~ ^[0-9]+$ ]] && usage_error report
start=$(iso_days_ago "$days")
end=$(now_iso)
_report "Last ${days}-day Focus" "$start" "$end"
day_noun="days"
[[ "$days" -eq 1 ]] && day_noun="day"
_report "Last ${days} ${day_noun}" "$start" "$end"
;;
*)
usage_error report
Expand Down
44 changes: 42 additions & 2 deletions tests/state-matrix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -261,10 +261,50 @@ sqlite3 "$REFOCUS_DB_PATH" "INSERT INTO sessions (project, start_time, end_time,
VALUES ('rep/x', '2026-06-12T10:00:00-03:00', '2026-06-12T12:00:00-03:00', 7200, 'r2', 0);"
printf 'r3\n' | ./focus past add rep/y 2026/06/12-13:00 2026/06/12-13:30 >/dev/null 2>&1
out=$(./focus report custom 90000 2>&1)
# The breakdown is a markdown table row now, so the count stands alone in its
# own cell rather than reading "N session(s)".
chk "report: multi-session project total" "0" \
"$([[ "$out" == *"rep/x"*"3h 0m"*"2 session"* ]]; echo $?)"
"$([[ "$out" == *'| `rep/x` | 3h 0m | 2 |'* ]]; echo $?)"
chk "report: single-session project total" "0" \
"$([[ "$out" == *"rep/y"*"30m"*"1 session"* ]]; echo $?)"
"$([[ "$out" == *'| `rep/y` | 30m | 1 |'* ]]; echo $?)"

# ── report: markdown structure ────────────────────────────────────────────────
# Notes are hand-written markdown. The old layout indented every note line
# behind a seven-space πŸ“ prefix, which turned bullets and bold into code
# blocks β€” the whole reason the report is emitted as markdown.
echo "── report: markdown structure ──"
printf -- '- bullet one\n- bullet two\n' | ./focus past add md/notes 2026/06/13-09:00 2026/06/13-10:30 >/dev/null 2>&1
printf 'manual note\n' | ./focus past add md/manual --duration 45m --date 2026/06/13 >/dev/null 2>&1
md=$(./focus report custom 90000 2>&1)

chk "md: document header" "0" "$([[ "$md" == *"# Focus report"* ]]; echo $?)"
chk "md: projects section" "0" "$([[ "$md" == *"## Projects"* ]]; echo $?)"
chk "md: table delimiter row" "0" "$([[ "$md" == *"|---|---:|---:|"* ]]; echo $?)"
chk "md: sessions section" "0" "$([[ "$md" == *"## Sessions"* ]]; echo $?)"
chk "md: heading carries id and project" "0" \
"$([[ "$md" == *'### ['*'] `md/notes`'* ]]; echo $?)"
chk "md: timestamped time line" "0" \
"$([[ "$md" == *"**2026-06-13 09:00–10:30 Β· 1h 30m**"* ]]; echo $?)"
chk "md: duration-only time line" "0" \
"$([[ "$md" == *"**45m on 2026-06-13 (manual)**"* ]]; echo $?)"

# The regression the format exists for: a note's own markdown must survive at
# column 0, not behind an indent.
chk "md: note bullets are not indented" "0" \
"$(echo "$md" | grep -q '^- bullet one$'; echo $?)"
chk "md: no πŸ“ prefix survives" "0" "$(echo "$md" | grep -c 'πŸ“' | tr -d ' ')"

# Rules separate sessions from each other, so the last one must not be left
# with one dangling after it.
chk "md: no trailing rule" "1" \
"$([[ "$(echo "$md" | grep -v '^$' | tail -1)" == "---" ]]; echo $?)"

# An empty period gets the header and the period line and stops β€” no rule
# trailing off the end of an otherwise blank document.
empty=$(REFOCUS_DB_PATH="$SANDBOX/empty-report.db" ./focus report today 2>&1); rc=$?
chk "md: empty period rc=0" "0" "$rc"
chk "md: empty period keeps the header" "0" "$([[ "$empty" == *"# Focus report"* ]]; echo $?)"
chk "md: empty period has no rule" "1" "$([[ "$empty" == *"---"* ]]; echo $?)"

# ── config show: BSD sed t-label ─────────────────────────────────────────────
# BSD sed reads a `;`-terminated `t` label as part of the label name and
Expand Down