diff --git a/core/text.sh b/core/text.sh index 42e0337..3e673a1 100755 --- a/core/text.sh +++ b/core/text.sh @@ -36,3 +36,37 @@ notes_block() { n=$(( n + 1 )) done < <(printf '%s\n' "$notes") } + +notes_merge_trail() { + # -> the note with + # the timestamps a merge is about to destroy appended to it [#36]. + # + # Folding a second session into an existing row turns it duration-only, so + # its start/end are dropped. Recording them in the note keeps the merge + # non-lossy. Empty arguments are skipped, which is what makes the second + # merge onto the same row read the way it should: the row is already + # duration-only by then, so it has no "Original" pair left to contribute + # and only the incoming session's pair gets appended. + local note="${1:-}" ofrom="${2:-}" oto="${3:-}" nfrom="${4:-}" nto="${5:-}" + local trail="" + [[ -n "$ofrom" ]] && trail="${trail}Original start time: $ofrom"$'\n' + [[ -n "$oto" ]] && trail="${trail}Original stop time: $oto"$'\n' + [[ -n "$nfrom" ]] && trail="${trail}New start time: $nfrom"$'\n' + [[ -n "$nto" ]] && trail="${trail}New stop time: $nto"$'\n' + + if [[ -z "$trail" ]]; then + printf '%s' "$note" + return 0 + fi + trail="${trail%$'\n'}" + + # Same trailing-newline discipline notes_block uses: strip what's there so + # the blank line between the note and the trail is exactly one, however + # many newlines $EDITOR left behind. + while [[ "$note" == *$'\n' ]]; do note="${note%$'\n'}"; done + if [[ -n "$note" ]]; then + printf '%s\n\n%s' "$note" "$trail" + else + printf '%s' "$trail" + fi +} diff --git a/docs/help/off.txt b/docs/help/off.txt index 9960f5b..719ce39 100644 --- a/docs/help/off.txt +++ b/docs/help/off.txt @@ -8,4 +8,10 @@ re-entry anchor for next time — for you, not a status report. Enter to skip. active duration = start → now paused duration = the time before you paused (pause time isn't counted) +If a session already carries this project name, off offers to append to it +instead of adding a second entry: your note opens with the original's text +loaded, the two durations add up, and the timestamps the merge drops are +written to the end of the note. Declining cancels the stop — the clock keeps +running, so you can answer it properly next time. + Always available, even from a stuck state — it's the way out. diff --git a/docs/help/past.txt b/docs/help/past.txt index b07a56d..cfae621 100644 --- a/docs/help/past.txt +++ b/docs/help/past.txt @@ -17,6 +17,14 @@ A duration-only session has no timestamps: modify can rename it or change its length, but won't bolt fake times onto it. --notes works on either kind — a note carries no timestamps. list shows the most recent first. +One project name, one row. add — and modify, when it renames onto a name +another session already holds — offers to append to that original instead of +creating a second entry: the durations add up, your note opens with the +original's text loaded, and the timestamps the merge drops are written to the +end of the note. modify also deletes the row it folded in. Declining writes +nothing. `modify --notes` touches neither name nor timing, so it never +asks. + Notes open in $EDITOR, so they can run to several lines. Piped input works too: echo "shipped the parser" | focus past add refactor 14:00 15:30 diff --git a/lib/off.sh b/lib/off.sh index 6fa31e2..e65c34a 100755 --- a/lib/off.sh +++ b/lib/off.sh @@ -6,6 +6,7 @@ source "$REFOCUS_ROOT/services/editor.sh" source "$REFOCUS_ROOT/core/time.sh" source "$REFOCUS_ROOT/core/text.sh" source "$REFOCUS_ROOT/services/help.sh" +source "$REFOCUS_ROOT/services/merge.sh" wants_help "$@" && show_help off @@ -31,6 +32,23 @@ else fi echo "" + +# A project already on a session row gets its time folded into that row rather +# than a second one beside it [#36]. Declining cancels the stop — the name was +# fixed at `focus on`, so there is nothing to correct here; the clock keeps +# running and `focus off` can be answered properly next time. +merge_rc=0 +merge_duplicate_session "$project" "$duration" "$start_time" "$now" || merge_rc=$? +if [[ $merge_rc -eq 2 ]]; then + echo "Cancelled — the session is still running." + exit 0 +fi +if [[ $merge_rc -eq 0 ]]; then + end_session "$now" + notify-send "Refocus" "Stopped: $project ($(( duration / 60 ))m)" 2>/dev/null || true + exit 0 +fi + echo "📝 What did you accomplish? (empty to skip)" notes=$(capture_notes "") diff --git a/lib/past.sh b/lib/past.sh index bf6c950..90f05ce 100755 --- a/lib/past.sh +++ b/lib/past.sh @@ -6,6 +6,7 @@ source "$REFOCUS_ROOT/services/editor.sh" source "$REFOCUS_ROOT/services/help.sh" source "$REFOCUS_ROOT/core/time.sh" source "$REFOCUS_ROOT/core/text.sh" +source "$REFOCUS_ROOT/services/merge.sh" # Before db_ensure and before any parsing: `past modify --help` used to reach # SQL and die on `WHERE id=--help`, and `past modify 5 --help` used to take @@ -16,6 +17,22 @@ db_ensure sub="${1:-list}"; shift || true +_merge_or_exit() { + # modify's half of the duplicate rule [#36]: + # . A rename onto a name another row already holds folds this row + # into that one and deletes it, so the two never coexist. Returns only when + # there is no duplicate and the caller should carry on with its UPDATE. + local id="$5" rc=0 + merge_duplicate_session "$1" "$2" "$3" "$4" "$id" || rc=$? + if [[ $rc -eq 0 ]]; then + delete_session "$id" + echo "✅ Session $id folded in and removed." + exit 0 + fi + [[ $rc -eq 2 ]] && { echo "Cancelled — session $id is unchanged."; exit 0; } + return 0 +} + _require_id() { # Session ids are integers. The adapter interpolates them into SQL, so a # non-numeric id produced a raw sqlite parse error instead of usage. [#25] @@ -59,6 +76,13 @@ case "$sub" in date_iso=$(parse_date_to_fmt "$date_str" "$DATE_FORMAT") || { echo "❌ Invalid date: $date_str" >&2; exit 2; } [[ -z "$date_iso" ]] && { echo "❌ Invalid date: $date_str" >&2; exit 2; } + # A duration-only add carries no timestamps, so it contributes no + # times to the merged row's note — only its length [#36]. + merge_rc=0 + merge_duplicate_session "$project" "$dur" "" "" || merge_rc=$? + [[ $merge_rc -eq 0 ]] && exit 0 + [[ $merge_rc -eq 2 ]] && { echo "Cancelled — nothing was added."; exit 0; } + echo "📝 Notes (empty to skip)"; notes=$(capture_notes "") record_duration_session "$project" "$dur" "$date_iso" "$notes" echo "✅ Added: $project ($dur_str on $date_iso)" @@ -75,6 +99,11 @@ case "$sub" in [[ $end_ts -le $start_ts ]] && { echo "❌ End must be after start." >&2; exit 2; } dur=$(( end_ts - start_ts )) + merge_rc=0 + merge_duplicate_session "$project" "$dur" "$start" "$end" || merge_rc=$? + [[ $merge_rc -eq 0 ]] && exit 0 + [[ $merge_rc -eq 2 ]] && { echo "Cancelled — nothing was added."; exit 0; } + echo "📝 Notes (empty to skip)"; notes=$(capture_notes "") record_session "$project" "$start" "$end" "$dur" "$notes" echo "✅ Added: $project ($(fmt_duration "$dur"))" @@ -100,6 +129,11 @@ case "$sub" in # after a no-op UPDATE. Say what the command can do instead. [#25] [[ $# -eq 0 && $want_notes -eq 0 ]] && { echo "❌ Nothing to change." >&2; usage_error past; } + # Recorded before the branches below consume it with `shift`. A bare + # `modify --notes` touches neither the name nor the timing, so it + # never needs the duplicate check [#36]. + _had_args=$# + row=$(get_session "$id") [[ -z "$row" ]] && { echo "❌ Session $id not found." >&2; exit 1; } IFS="|" read -r _ cur_proj cur_start cur_end cur_dur cur_notes cur_donly _ <<< "$row" @@ -119,6 +153,9 @@ case "$sub" in echo "❌ Session $id is duration-only. Timestamps cannot be edited." >&2 usage_error past fi + if [[ $_had_args -gt 0 ]]; then + _merge_or_exit "$new_proj" "$new_dur" "" "" "$id" + fi update_duration_session "$id" "$new_proj" "$new_dur" elif [[ $# -gt 0 ]]; then new_proj="${1:-$cur_proj}" @@ -134,6 +171,7 @@ case "$sub" in e_ts=$(iso_to_epoch "$new_end") new_dur=$(( e_ts - s_ts )) + _merge_or_exit "$new_proj" "$new_dur" "$new_start" "$new_end" "$id" update_session "$id" "$new_proj" "$new_start" "$new_end" "$new_dur" fi diff --git a/services/database.sh b/services/database.sh index f4a7adb..d32c16e 100755 --- a/services/database.sh +++ b/services/database.sh @@ -200,6 +200,21 @@ update_session_notes() { _exec "UPDATE sessions SET notes='$(_q "$notes")' WHERE id=$id;" } +fold_session_into() { + # -> collapse a second session + # for the same project into the row that already holds that name [#36]. + # The row stops describing one contiguous span the moment two of them share + # it, so it becomes duration-only and drops its timestamps [CONV-DURONLY]; + # the caller writes the dropped times into the note first, which is the + # only record of them that survives. + local id="$1" duration="$2" date="$3" notes="${4:-}" + _exec "UPDATE sessions SET + duration_seconds=$duration, notes='$(_q "$notes")', + duration_only=1, session_date='$(_q "$date")', + start_time=NULL, end_time=NULL + WHERE id=$id;" +} + delete_session() { local id="$1" _exec "DELETE FROM sessions WHERE id=$id;" @@ -277,6 +292,22 @@ get_session() { FROM sessions WHERE id=$id;" } +get_session_by_project() { + # [exclude-id] -> the ORIGINAL session carrying that exact project + # name (lowest id wins), as an 8-field row like get_session, or empty when + # nothing holds the name. Every write path checks this before adding a row, + # so a project name never ends up split across duplicate entries [#36]. + # The name is sanitized the same way the write paths sanitize it, or the + # lookup would miss the row it is about to duplicate. + local project; project=$(sanitize_pipe "$1") + local exclude="${2:-}" where + where="project='$(_q "$project")'" + [[ -n "$exclude" ]] && where="$where AND id<>$exclude" + _query "SELECT id, project, COALESCE(start_time,''), COALESCE(end_time,''), + duration_seconds, $_NOTES_ENCODED, duration_only, COALESCE(session_date,'') + FROM sessions WHERE $where ORDER BY id ASC LIMIT 1;" +} + get_total_time() { local project="$1" _query "SELECT COALESCE(SUM(duration_seconds),0) diff --git a/services/merge.sh b/services/merge.sh new file mode 100755 index 0000000..4f3090b --- /dev/null +++ b/services/merge.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Refocus Shell - Duplicate-session merge (secondary adapter) +# +# One project name, one session row. `focus off`, `past add` and `past modify` +# each route through merge_duplicate_session before writing: if a row already +# carries the name, the new time is folded into that original row instead of +# landing beside it as a second entry. Two rows named the same thing, with +# overlapping clock times, are indistinguishable in a report — which is what +# issue #36 was. [#36] +# +# Sourced by handlers, never routable — a file under services/ is not a +# command. Assumes the caller already sourced env.sh, services/database.sh, +# services/editor.sh, core/time.sh and core/text.sh; every handler that writes +# a session already sources all five. + +_merge_ts() { + # stored ISO -> display string, or nothing when there is no timestamp. + # Never fails: an unparseable stored value prints as-is rather than + # aborting a merge that is already half-decided. + local iso="${1:-}" + [[ -z "$iso" ]] || ts_format "$iso" "$DATE_SHORT_FORMAT" 2>/dev/null || printf '%s' "$iso" +} + +_merge_date() { + # ... -> the calendar date the merged row should carry, from the first + # timestamp that parses; today if the original row has none to offer. + local iso + for iso in "$@"; do + [[ -z "$iso" ]] && continue + ts_format "$iso" "$DATE_FORMAT" 2>/dev/null && return 0 + done + parse_date_to_fmt "today" "$DATE_FORMAT" +} + +merge_duplicate_session() { + # [exclude-id] -> offer to fold + # this session into the row that already holds . start/end may be + # empty (a duration-only session has no timestamps). exclude-id keeps + # `past modify` from matching the very row it is editing. + # + # Returns: + # 0 folded — the caller must NOT write a row of its own + # 1 nothing else holds the name — the caller proceeds normally + # 2 declined — the caller must cancel without writing anything + # + # Fatal errors exit the handler outright rather than returning: callers + # invoke this in a `|| rc=$?` list, which suspends set -e for the whole + # function body, so a failed note capture or a failed write would + # otherwise sail on and overwrite the original note with nothing. + local project="$1" seconds="$2" new_start="${3:-}" new_end="${4:-}" exclude="${5:-}" + + local row; row=$(get_session_by_project "$project" "$exclude") + [[ -z "$row" ]] && return 1 + + local oid ostart oend odur onotes odonly odate + IFS='|' read -r oid _ ostart oend odur onotes odonly odate <<< "$row" + + local total=$(( odur + seconds )) + echo "⚠ Session $oid is already named '$project' ($(fmt_duration "$odur") logged)." + printf ' A session with the same name exists, append to original note? (y/N): ' + local ans="" + read -r ans || true + [[ "$ans" =~ ^[Yy]$ ]] || return 2 + + echo "📝 Notes — this is the original session's note; add to it (empty to keep as is)" + local notes + notes=$(capture_notes "$(notes_decode "$onotes")") || exit 2 + + # The row only kept its timestamps while it described one span. Write them + # into the note before dropping them; a row that is already duration-only + # has none left to preserve and contributes nothing here. + local ofrom="" oto="" + if [[ "$odonly" != "1" ]]; then + ofrom=$(_merge_ts "$ostart") + oto=$(_merge_ts "$oend") + fi + local nfrom nto + nfrom=$(_merge_ts "$new_start") + nto=$(_merge_ts "$new_end") + notes=$(notes_merge_trail "$notes" "$ofrom" "$oto" "$nfrom" "$nto") + + local date="$odate" + [[ -z "$date" ]] && date=$(_merge_date "$ostart" "$oend") + + fold_session_into "$oid" "$total" "$date" "$notes" || exit 1 + echo "✅ Appended to session $oid: $project is now $(fmt_duration "$total") on $date." + return 0 +} diff --git a/tests/state-matrix.sh b/tests/state-matrix.sh index c8527ca..147ab0b 100755 --- a/tests/state-matrix.sh +++ b/tests/state-matrix.sh @@ -112,7 +112,7 @@ chk "enable: not disabled" "0" "$(sqlite3 "$REFOCUS_DB_PATH" "SELECT focus_disab # ── on guard: already active ───────────────────────────────────────────────── echo "── on guard: already active ──" -./focus on fyc/work >/dev/null 2>&1 +./focus on fyc/guard >/dev/null 2>&1 ./focus on fyc/other >/dev/null 2>&1; chk "on@active rc=1" "1" "$?" printf 'done\n' | ./focus off >/dev/null 2>&1 @@ -253,7 +253,12 @@ echo "── report: bash-3.2 compat ──" if grep -q '^[^#]*declare -A' lib/report.sh; then assoc_array_found=yes; else assoc_array_found=no; fi chk "report.sh has no associative array" "no" "$assoc_array_found" printf 'r1\n' | ./focus past add rep/x 2026/06/12-09:00 2026/06/12-10:00 >/dev/null 2>&1 -printf 'r2\n' | ./focus past add rep/x 2026/06/12-10:00 2026/06/12-12:00 >/dev/null 2>&1 +# Two rows under one project name: `past add` now folds a same-named session +# into the original instead [#36], so the second row is seeded directly. The +# aggregation still has to work for rows that arrived before the rule, or +# through import. +sqlite3 "$REFOCUS_DB_PATH" "INSERT INTO sessions (project, start_time, end_time, duration_seconds, notes, duration_only) + 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) chk "report: multi-session project total" "0" \ @@ -407,7 +412,7 @@ bash focus-checkin >/dev/null 2>&1 chk "checkin@active: silent no-op" "$before" "$(cnt)" printf 'n\n' | ./focus off >/dev/null 2>&1 -./focus on checkin/guard >/dev/null 2>&1 +./focus on checkin/guard-paused >/dev/null 2>&1 ./focus pause >/dev/null 2>&1 before=$(cnt) bash focus-checkin >/dev/null 2>&1 @@ -532,6 +537,106 @@ out=$(bash -c " chk "range@+10:00: today's session included" "0" "$([[ "$out" == *"range-today"* ]]; echo $?)" chk "range@+10:00: yesterday's session excluded" "0" "$([[ "$out" != *"range-yesterday"* ]]; echo $?)" +# ── duplicate sessions: one project name, one row [#36] ─────────────────────── +# Two rows with the same project name and overlapping clock times are +# indistinguishable in a report. Every write path now offers to fold the new +# time into the row that already holds the name instead of adding a second one. +echo "── duplicates: fold into the original [#36] ──" +./focus enable >/dev/null 2>&1 || true + +_notes_of() { sqlite3 "$REFOCUS_DB_PATH" "SELECT notes FROM sessions WHERE project='$1';"; } +_rows_of() { sqlite3 "$REFOCUS_DB_PATH" "SELECT COUNT(*) FROM sessions WHERE project='$1';"; } + +printf 'first\n' | ./focus past add dup/add 2026/06/20-10:00 2026/06/20-11:00 >/dev/null 2>&1 + +# Declining writes nothing at all. +printf 'n\n' | ./focus past add dup/add 2026/06/20-12:00 2026/06/20-13:00 >/dev/null 2>&1 +chk "add@dup declined: rc=0" "0" "$?" +chk "add@dup declined: no new row" "1" "$(_rows_of dup/add)" +chk "add@dup declined: dur untouched" "3600" "$(dur dup/add)" + +# Accepting folds: one row, summed duration, and the timestamps the fold drops +# are written into the note instead. +printf 'y\nsecond\n' | ./focus past add dup/add 2026/06/20-12:00 2026/06/20-13:00 >/dev/null 2>&1 +chk "add@dup folded: still one row" "1" "$(_rows_of dup/add)" +chk "add@dup folded: durations sum" "7200" "$(dur dup/add)" +chk "add@dup folded: now duration-only" "1" \ + "$(sqlite3 "$REFOCUS_DB_PATH" "SELECT duration_only FROM sessions WHERE project='dup/add';")" +chk "add@dup folded: note carries both spans" "second + +Original start time: 2026-06-20 10:00 +Original stop time: 2026-06-20 11:00 +New start time: 2026-06-20 12:00 +New stop time: 2026-06-20 13:00" "$(_notes_of dup/add)" + +# A second fold has no "Original" pair left to preserve — that row gave its +# timestamps up on the first one — so only the incoming span is appended. +printf 'y\nthird\n' | ./focus past add dup/add 2026/06/21-08:00 2026/06/21-09:30 >/dev/null 2>&1 +chk "add@dup refolded: durations sum" "12600" "$(dur dup/add)" +chk "add@dup refolded: only the new span appended" "third + +New start time: 2026-06-21 08:00 +New stop time: 2026-06-21 09:30" "$(_notes_of dup/add)" + +# `past add --duration` has no timestamps to contribute; it folds its length in +# and leaves the trail alone. +printf 'dur-first\n' | ./focus past add dup/duronly --duration 1h --date 2026/06/20 >/dev/null 2>&1 +printf 'y\ndur-second\n' | ./focus past add dup/duronly --duration 30m --date 2026/06/21 >/dev/null 2>&1 +chk "add --duration@dup: one row" "1" "$(_rows_of dup/duronly)" +chk "add --duration@dup: durations sum" "5400" "$(dur dup/duronly)" +chk "add --duration@dup: no timestamp trail" "dur-second" "$(_notes_of dup/duronly)" + +# focus off: declining leaves the clock running — the name was fixed at +# `focus on`, so there is nothing else to correct here. +./focus on dup/live >/dev/null 2>&1 +printf 'live one\n' | ./focus off >/dev/null 2>&1 +./focus on dup/live >/dev/null 2>&1 +printf 'n\n' | ./focus off >/dev/null 2>&1 +chk "off@dup declined: rc=0" "0" "$?" +chk "off@dup declined: still active" "1|0|0|dup/live" "$(st)" +chk "off@dup declined: no second row" "1" "$(_rows_of dup/live)" + +printf 'y\nlive two\n' | ./focus off >/dev/null 2>&1 +chk "off@dup accepted: idle" "0|0|0|-" "$(st)" +chk "off@dup accepted: still one row" "1" "$(_rows_of dup/live)" + +# past modify renaming onto a name another row holds folds the two together +# and removes the row being edited. +printf 'to-merge\n' | ./focus past add dup/source 2026/06/22-10:00 2026/06/22-12:00 >/dev/null 2>&1 +sid=$(sqlite3 "$REFOCUS_DB_PATH" "SELECT id FROM sessions WHERE project='dup/source';") +printf 'y\nmerged\n' | ./focus past modify "$sid" dup/add >/dev/null 2>&1 +chk "modify@dup: rc=0" "0" "$?" +chk "modify@dup: source row removed" "0" "$(_rows_of dup/source)" +chk "modify@dup: target absorbed it" "19800" "$(dur dup/add)" + +printf 'keep-me\n' | ./focus past add dup/keep 2026/06/22-14:00 2026/06/22-15:00 >/dev/null 2>&1 +kid=$(sqlite3 "$REFOCUS_DB_PATH" "SELECT id FROM sessions WHERE project='dup/keep';") +printf 'n\n' | ./focus past modify "$kid" dup/add >/dev/null 2>&1 +chk "modify@dup declined: rc=0" "0" "$?" +chk "modify@dup declined: row intact" "1" "$(_rows_of dup/keep)" +chk "modify@dup declined: target untouched" "19800" "$(dur dup/add)" + +# A bare `modify --notes` changes neither name nor timing, so it must not +# ask about duplicates — even when the name really is duplicated (rows that +# predate the rule, or arrived by import). +sqlite3 "$REFOCUS_DB_PATH" "INSERT INTO sessions (project, start_time, end_time, duration_seconds, notes, duration_only) + VALUES ('dup/legacy', '2026-06-23T10:00:00-03:00', '2026-06-23T11:00:00-03:00', 3600, 'a', 0), + ('dup/legacy', '2026-06-23T12:00:00-03:00', '2026-06-23T13:00:00-03:00', 3600, 'b', 0);" +lid=$(sqlite3 "$REFOCUS_DB_PATH" "SELECT MIN(id) FROM sessions WHERE project='dup/legacy';") +printf 'rewritten\n' | ./focus past modify "$lid" --notes >/dev/null 2>&1 +chk "modify --notes@dup: rc=0" "0" "$?" +chk "modify --notes@dup: no fold" "2" "$(_rows_of dup/legacy)" +chk "modify --notes@dup: note rewritten" "rewritten" \ + "$(sqlite3 "$REFOCUS_DB_PATH" "SELECT notes FROM sessions WHERE id=$lid;")" + +# notes_merge_trail is pure string work: empty timestamps drop out entirely, so +# a fold with nothing to preserve returns the note untouched. +chk "notes_merge_trail: no timestamps, note unchanged" "just a note" \ + "$(bash -c "source core/text.sh; notes_merge_trail 'just a note' '' '' '' ''")" +chk "notes_merge_trail: empty note keeps no leading blank line" "New start time: A +New stop time: B" \ + "$(bash -c "source core/text.sh; notes_merge_trail '' '' '' 'A' 'B'")" + # ── result ─────────────────────────────────────────────────────────────────── echo total=$(( pass + fail ))