diff --git a/README.md b/README.md index e4096af..ba36887 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/help/report.txt b/docs/help/report.txt index e80cc58..9494a7d 100644 --- a/docs/help/report.txt +++ b/docs/help/report.txt @@ -10,3 +10,11 @@ Plain facts โ€” no score attached. month last 30 days custom last 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. diff --git a/lib/report.sh b/lib/report.sh index b3a0924..53f6164 100755 --- a/lib/report.sh +++ b/lib/report.sh @@ -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 @@ -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") } @@ -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 diff --git a/tests/state-matrix.sh b/tests/state-matrix.sh index 92fbb75..4fecb8b 100755 --- a/tests/state-matrix.sh +++ b/tests/state-matrix.sh @@ -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