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
187 changes: 176 additions & 11 deletions spec/commands/time_worked/week_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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("<space>", " ")
⚠️ 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 -<space>
🚧 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("<space>", " ")
⚠️ 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 -<space>
🚧 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("<space>", " ")
⚠️ 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)
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/tanda_cli/models/shift_summary.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
95 changes: 80 additions & 15 deletions src/tanda_cli/models/shift_summary/worked_shift.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 19 additions & 9 deletions src/tanda_cli/representers/shift.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion src/tanda_cli/representers/shift_summary.cr
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,19 @@ 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
label = worked_shift.ongoing? ? "Worked so far:" : "Time worked:"
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
Expand Down
3 changes: 2 additions & 1 deletion src/tanda_cli/types/shift.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down