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
34 changes: 34 additions & 0 deletions core/text.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,37 @@ notes_block() {
n=$(( n + 1 ))
done < <(printf '%s\n' "$notes")
}

notes_merge_trail() {
# <note> <orig-start> <orig-stop> <new-start> <new-stop> -> 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
}
6 changes: 6 additions & 0 deletions docs/help/off.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 8 additions & 0 deletions docs/help/past.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> --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

Expand Down
18 changes: 18 additions & 0 deletions lib/off.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 "")

Expand Down
38 changes: 38 additions & 0 deletions lib/past.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +17,22 @@ db_ensure

sub="${1:-list}"; shift || true

_merge_or_exit() {
# modify's half of the duplicate rule [#36]: <project> <seconds> <start>
# <end> <id>. 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]
Expand Down Expand Up @@ -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)"
Expand All @@ -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"))"
Expand All @@ -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 <id> --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"
Expand All @@ -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}"
Expand All @@ -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

Expand Down
31 changes: 31 additions & 0 deletions services/database.sh
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,21 @@ update_session_notes() {
_exec "UPDATE sessions SET notes='$(_q "$notes")' WHERE id=$id;"
}

fold_session_into() {
# <id> <total-seconds> <session-date> <notes> -> 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;"
Expand Down Expand Up @@ -277,6 +292,22 @@ get_session() {
FROM sessions WHERE id=$id;"
}

get_session_by_project() {
# <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)
Expand Down
88 changes: 88 additions & 0 deletions services/merge.sh
Original file line number Diff line number Diff line change
@@ -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() {
# <iso>... -> 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() {
# <project> <seconds> <start-iso> <end-iso> [exclude-id] -> offer to fold
# this session into the row that already holds <project>. 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
}
Loading