From 7bfe87adadf0f110f080af2956fdb004d638b1e0 Mon Sep 17 00:00:00 2001 From: Daniel Gilchrist Date: Tue, 17 Feb 2026 01:22:13 +0000 Subject: [PATCH] Time Worked - Better handle forgetting to clock out on a previous day --- spec/commands/time_worked/week_spec.cr | 187 ++++++++++++++++-- src/tanda_cli/models/shift_summary.cr | 2 +- .../models/shift_summary/worked_shift.cr | 95 +++++++-- src/tanda_cli/representers/shift.cr | 28 ++- src/tanda_cli/representers/shift_summary.cr | 7 +- src/tanda_cli/types/shift.cr | 3 +- 6 files changed, 284 insertions(+), 38 deletions(-) diff --git a/spec/commands/time_worked/week_spec.cr b/spec/commands/time_worked/week_spec.cr index c398a717..4c909f04 100644 --- a/spec/commands/time_worked/week_spec.cr +++ b/spec/commands/time_worked/week_spec.cr @@ -252,6 +252,169 @@ describe TandaCLI::Commands::TimeWorked::Week do context.stdout.to_s.should eq(expected) end end + + it "Assumes expected finish time for clock out if forgotten on a previous day" do + WebMock + .stub(:get, endpoint(Regex.new("/shifts"))) + .to_return( + status: 200, + body: [ + build_shift( + id: 1, + start: Time.local(2024, 12, 23, 8, 30), + finish: nil, + break_start: Time.local(2024, 12, 23, 12), + break_finish: Time.local(2024, 12, 23, 12, 30) + ), + build_shift( + id: 2, + start: Time.local(2024, 12, 24, 8, 30), + finish: nil, + break_start: Time.local(2024, 12, 24, 12), + break_finish: Time.local(2024, 12, 24, 12, 30) + ), + ].to_json, + ) + + travel_to(Time.local(2024, 12, 24, 14)) do + context = run(["time_worked", "week", "--display"]) + + expected = <<-OUTPUT.gsub("", " ") + ⚠️ Warning: Missing finish time for Monday, assuming regular hours finish time + Time worked: 8 hours and 0 minutes + πŸ“… Monday, 23 Dec 2024 + πŸ•“ 8:30 am - 5:00 pm + 🚧 Pending + β˜•οΈ Breaks: + πŸ•“ 12:00 pm - 12:30 pm + ⏸️ 30 minutes + πŸ’° false + + Worked so far: 5 hours and 0 minutes + πŸ“… Tuesday, 24 Dec 2024 + πŸ•“ 8:30 am - + 🚧 Pending + β˜•οΈ Breaks: + πŸ•“ 12:00 pm - 12:30 pm + ⏸️ 30 minutes + πŸ’° false + + Time left today: 3 hours and 0 minutes + You can clock out at: 5:00 pm + + You've worked 13 hours and 0 minutes this week + + OUTPUT + + context.stdout.to_s.should eq(expected) + end + end + + it "Shows overtime if previous day filled and past expected finish" do + WebMock + .stub(:get, endpoint(Regex.new("/shifts"))) + .to_return( + status: 200, + body: [ + build_shift( + id: 1, + start: Time.local(2024, 12, 23, 8, 30), + finish: nil, + break_start: Time.local(2024, 12, 23, 12), + break_finish: Time.local(2024, 12, 23, 12, 30) + ), + build_shift( + id: 2, + start: Time.local(2024, 12, 24, 8, 30), + finish: nil, + break_start: Time.local(2024, 12, 24, 12), + break_finish: Time.local(2024, 12, 24, 12, 30) + ), + ].to_json, + ) + + travel_to(Time.local(2024, 12, 24, 19)) do + context = run(["time_worked", "week", "--display"]) + + expected = <<-OUTPUT.gsub("", " ") + ⚠️ Warning: Missing finish time for Monday, assuming regular hours finish time + Time worked: 8 hours and 0 minutes + πŸ“… Monday, 23 Dec 2024 + πŸ•“ 8:30 am - 5:00 pm + 🚧 Pending + β˜•οΈ Breaks: + πŸ•“ 12:00 pm - 12:30 pm + ⏸️ 30 minutes + πŸ’° false + + Worked so far: 10 hours and 0 minutes + πŸ“… Tuesday, 24 Dec 2024 + πŸ•“ 8:30 am - + 🚧 Pending + β˜•οΈ Breaks: + πŸ•“ 12:00 pm - 12:30 pm + ⏸️ 30 minutes + πŸ’° false + + Overtime this week: 2 hours and 0 minutes + Overtime since: 5:00 pm + + You've worked 18 hours and 0 minutes this week + + OUTPUT + + context.stdout.to_s.should eq(expected) + end + end + + it "Assumes regular hours for next day with no breaks" do + WebMock + .stub(:get, endpoint(Regex.new("/shifts"))) + .to_return( + status: 200, + body: [ + build_shift( + id: 1, + start: Time.local(2024, 12, 23, 8, 30), + finish: nil, + break_start: nil, + break_finish: nil + ), + build_shift( + id: 2, + start: Time.local(2024, 12, 24, 8, 30), + finish: nil, + break_start: nil, + break_finish: nil + ), + ].to_json, + ) + + travel_to(Time.local(2024, 12, 25, 1)) do + context = run(["time_worked", "week", "--display"]) + + expected = <<-OUTPUT.gsub("", " ") + ⚠️ Warning: Missing finish time for Monday, assuming regular hours finish time + Time worked: 8 hours and 0 minutes + πŸ“… Monday, 23 Dec 2024 + πŸ•“ 8:30 am - 5:00 pm + 🚧 Pending + β˜•οΈ 30 minutes + + ⚠️ Warning: Missing finish time for Tuesday, assuming regular hours finish time + Time worked: 8 hours and 0 minutes + πŸ“… Tuesday, 24 Dec 2024 + πŸ•“ 8:30 am - 5:00 pm + 🚧 Pending + β˜•οΈ 30 minutes + + You've worked 16 hours and 0 minutes this week + + OUTPUT + + context.stdout.to_s.should eq(expected) + end + end end private def build_shift(id, start, finish, break_start, break_finish) @@ -265,17 +428,19 @@ private def build_shift(id, start, finish, break_start, break_finish) break_finish: break_finish.try(&.to_unix), break_length: 30, breaks: [ - { - id: 1, - selected_automatic_break_rule_id: nil, - shift_id: id, - start: break_start.try(&.to_unix), - finish: break_finish.try(&.to_unix), - length: 30, - paid: false, - updated_at: 1735259689, - }, - ], + if break_start || break_finish + { + id: 1, + selected_automatic_break_rule_id: nil, + shift_id: id, + start: break_start.try(&.to_unix), + finish: break_finish.try(&.to_unix), + length: 30, + paid: false, + updated_at: 1735259689, + } + end, + ].compact, finish: finish.try(&.to_unix), department_id: 1, sub_cost_centre: nil, diff --git a/src/tanda_cli/models/shift_summary.cr b/src/tanda_cli/models/shift_summary.cr index b2808eeb..e663a8fe 100644 --- a/src/tanda_cli/models/shift_summary.cr +++ b/src/tanda_cli/models/shift_summary.cr @@ -74,7 +74,7 @@ module TandaCLI private def classify(shifts : Array(Types::Shift), treat_paid_breaks_as_unpaid : Bool) : Array(ClassifiedShift) shifts.map do |shift| LeaveShift.from?(shift) || - WorkedShift.from(shift, treat_paid_breaks_as_unpaid) + WorkedShift.from(shift, treat_paid_breaks_as_unpaid, @regular_hours_schedules) end end diff --git a/src/tanda_cli/models/shift_summary/worked_shift.cr b/src/tanda_cli/models/shift_summary/worked_shift.cr index bd7aa784..2ee25e7e 100644 --- a/src/tanda_cli/models/shift_summary/worked_shift.cr +++ b/src/tanda_cli/models/shift_summary/worked_shift.cr @@ -2,30 +2,95 @@ module TandaCLI module Models struct ShiftSummary struct WorkedShift - def self.from(shift : Types::Shift, treat_paid_breaks_as_unpaid : Bool = false) : WorkedShift - time_worked = shift.time_worked(treat_paid_breaks_as_unpaid) - worked_so_far = shift.worked_so_far(treat_paid_breaks_as_unpaid) - - new( - shift, - time_worked: time_worked || worked_so_far, - ongoing: time_worked.nil? && !worked_so_far.nil?, - ) + alias RegularHoursSchedule = Configuration::Serialisable::Organisation::RegularHoursSchedule + + def self.from( + shift : Types::Shift, + treat_paid_breaks_as_unpaid : Bool = false, + regular_hours_schedules : Array(RegularHoursSchedule)? = nil, + ) : WorkedShift + new(shift, treat_paid_breaks_as_unpaid, regular_hours_schedules) end def initialize( @shift : Types::Shift, - time_worked : Time::Span?, - @ongoing : Bool, + @treat_paid_breaks_as_unpaid : Bool = false, + @regular_hours_schedules : Array(RegularHoursSchedule)? = nil, ) - @time_worked = time_worked || Time::Span.zero end getter shift : Types::Shift - getter time_worked : Time::Span - getter? ongoing : Bool - delegate :ongoing?, to: shift + delegate :date, to: shift + + def time_worked : Time::Span + resolved_time_worked || Time::Span.zero + end + + def ongoing? : Bool + shift.time_worked(@treat_paid_breaks_as_unpaid).nil? && + !shift.worked_so_far(@treat_paid_breaks_as_unpaid).nil? + end + + def assumed_finish? : Bool + !expected_finish_time.nil? + end + + def expected_finish_time : Time? + matching_schedule.try(&.finish_time) + end + + def expected_break_length : Time::Span? + matching_schedule.try(&.break_length) + end + + def shift_representer : Representers::Shift + Representers::Shift.new(shift, expected_finish_time, expected_break_length) + end + + private def resolved_time_worked : Time::Span? + shift.time_worked(@treat_paid_breaks_as_unpaid) || + expected_time_worked || + shift.worked_so_far(@treat_paid_breaks_as_unpaid) + end + + private def expected_time_worked : Time::Span? + schedule = matching_schedule + return unless schedule + + calculate_expected_time_worked(schedule) + end + + private def matching_schedule : RegularHoursSchedule? + return if shift.time_worked(@treat_paid_breaks_as_unpaid) + return if shift.finish_time + + schedules = @regular_hours_schedules + return unless schedules + return if shift.date.date == Utils::Time.now.date + + schedules.find(&.day_of_week.==(shift.day_of_week)) + end + + private def calculate_expected_time_worked(schedule : RegularHoursSchedule) : Time::Span? + start_time = shift.start_time + return unless start_time + + expected_finish = Time.local( + shift.date.year, + shift.date.month, + shift.date.day, + schedule.finish_time.hour, + schedule.finish_time.minute, + location: Utils::Time.location + ) + + actual_break_time = (@treat_paid_breaks_as_unpaid ? shift.valid_breaks : shift.valid_breaks.reject(&.paid?)).sum(&.ongoing_length).minutes + expected_break_time = actual_break_time == 0.minutes ? schedule.break_length : 0.minutes + total_break_time = actual_break_time + expected_break_time + + (expected_finish - start_time) - total_break_time + end end end end diff --git a/src/tanda_cli/representers/shift.cr b/src/tanda_cli/representers/shift.cr index 2da8c142..d9db7dda 100644 --- a/src/tanda_cli/representers/shift.cr +++ b/src/tanda_cli/representers/shift.cr @@ -8,27 +8,37 @@ require "../types/shift_break" module TandaCLI module Representers struct Shift < Base(Types::Shift) + def initialize(@object : Types::Shift, @expected_finish_time : Time? = nil, @expected_break_length : Time::Span? = nil) + end + private def build_display(builder : Builder) builder << "πŸ“… #{@object.pretty_date}\n" + pretty_expected_finish_time = @expected_finish_time.try { |time| Utils::Time.pretty_time(time) } pretty_start = @object.pretty_start_time - pretty_finish = @object.pretty_finish_time + pretty_finish = @object.pretty_finish_time || pretty_expected_finish_time + pretty_finish = pretty_finish.colorize.yellow if pretty_finish && pretty_expected_finish_time builder << "πŸ•“ #{pretty_start} - #{pretty_finish}\n" if pretty_start || pretty_finish builder << "🚧 #{@object.status}\n" - build_shift_breaks(builder) if @object.valid_breaks.present? + build_shift_breaks(builder) if @object.valid_breaks.present? || @expected_break_length build_notes(builder) if @object.notes.present? end private def build_shift_breaks(builder : Builder) - builder << "β˜•οΈ Breaks:\n".colorize.white.bold - valid_breaks = @object.valid_breaks - last_break_index = valid_breaks.size - 1 - - valid_breaks.sort_by(&.id).each_with_index do |shift_break, index| - ShiftBreak.new(shift_break).build(builder) - builder << '\n' if index != last_break_index + expected_break_length = @expected_break_length + + if (breaks = @object.valid_breaks).present? + builder << "β˜•οΈ Breaks:\n".colorize.white.bold + last_break_index = breaks.size - 1 + + breaks.sort_by(&.id).each_with_index do |shift_break, index| + ShiftBreak.new(shift_break).build(builder) + builder << '\n' if index != last_break_index + end + elsif expected_break_length && !expected_break_length.zero? + builder << "β˜•οΈ #{expected_break_length.total_minutes.to_i} minutes\n".colorize.yellow end end diff --git a/src/tanda_cli/representers/shift_summary.cr b/src/tanda_cli/representers/shift_summary.cr index 1d36f675..20fda8aa 100644 --- a/src/tanda_cli/representers/shift_summary.cr +++ b/src/tanda_cli/representers/shift_summary.cr @@ -30,6 +30,11 @@ module TandaCLI end private def build_worked_shift(builder : Builder, worked_shift : Models::ShiftSummary::WorkedShift) + if worked_shift.assumed_finish? + day_name = worked_shift.date.to_s("%A") + builder.puts "#{"⚠️ Warning:".colorize.yellow.bold} Missing finish time for #{day_name}, assuming regular hours finish time" + end + time_worked = worked_shift.time_worked if time_worked @@ -37,7 +42,7 @@ module TandaCLI builder.puts "#{"#{label}".colorize.white.bold} #{time_worked.hours} hours and #{time_worked.minutes} minutes" end - Shift.new(worked_shift.shift).build(builder) + worked_shift.shift_representer.build(builder) end end end diff --git a/src/tanda_cli/types/shift.cr b/src/tanda_cli/types/shift.cr index eb94f21f..97819d3c 100644 --- a/src/tanda_cli/types/shift.cr +++ b/src/tanda_cli/types/shift.cr @@ -101,8 +101,9 @@ module TandaCLI def ongoing? : Bool return false unless start_time + return false unless finish_time.nil? - finish_time.nil? + date.date == Utils::Time.now.date end def ongoing_break? : Bool