Skip to content

Commit e3a5f32

Browse files
committed
wip
1 parent 7b82d8a commit e3a5f32

7 files changed

+87
-46
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,5 @@ go.work.sum
3030

3131
# build
3232
/ocs
33+
/ocsctl
3334
/demo.json

README.md

+8-4
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,22 @@
22
[![License](https://img.shields.io/github/license/orltom/on-call-schedule)](/LICENSE)
33

44
# On-Call Schedule
5-
It helps to create or synchronize on-call schedules for team members, allowing users to define custom rules or use
5+
It helps to create or synchronize on-call schedules, allowing users to define custom rules or use
66
predefined ones. Important factors, such as absences due to holidays or other time off, are automatically considered.
77

8-
## Usage
8+
## Create on-call schedule plan
9+
10+
11+
### Usage
912
Here is an example of how to create an on-call duty plan
1013
```shell
11-
> cat > demo.json << EOL
14+
cat > demo.json << EOL
1215
{
1316
"employees": [
1417
{"id": "joe@example.com", "name": "Joe"},
1518
{"id": "jan@example.com", "name": "Jan", "vacationDays": ["2024-01-06","2024-01-07"]},
16-
{"id": "lee@example.com", "name": "Lee"}
19+
{"id": "lee@example.com", "name": "Lee"},
20+
{"id": "eva@example.com", "name": "Eva"}
1721
]
1822
}
1923
EOL

cmd/create_command.go

+39-26
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"flag"
77
"fmt"
88
"os"
9+
"strings"
910
"time"
1011

1112
"github.com/orltom/on-call-schedule/internal/cli"
@@ -41,20 +42,22 @@ func RunCreateShiftPlan(arguments []string) error {
4142
},
4243
}
4344

44-
str := new(time.Time)
45+
start := new(time.Time)
4546
end := new(time.Time)
47+
enabledRules := new(string)
4648
duration := new(int)
4749
teamFilePath := new(string)
4850
transform := Table
4951
var showHelp bool
5052

5153
createCommand := flag.NewFlagSet("create", flag.ExitOnError)
5254
createCommand.BoolVar(&showHelp, "h", false, "help for ocsctl create")
53-
createCommand.IntVar(duration, "duration", 7*24, "shift duration in hours.")
54-
createCommand.Func("start", "(required) start time of the schedule plan", cli.TimeValueVar(str))
55+
createCommand.IntVar(duration, "duration", 7*24, "shift duration in hours")
56+
createCommand.Func("start", "(required) start time of the schedule plan", cli.TimeValueVar(start))
5557
createCommand.Func("end", "(required) end time of the schedule plan", cli.TimeValueVar(end))
5658
createCommand.Func("team-file", "(required) path to the file that contain all on-call duties", cli.FilePathVar(teamFilePath))
5759
createCommand.Func("output", "output format. One of (cvs, table, json)", cli.EnumValueVar(enums, &transform))
60+
createCommand.StringVar(enabledRules, "enabled-rules", "vacation", "Rule to decide which employee should be on-call for the next shift")
5861
createCommand.Usage = func() {
5962
fmt.Fprintf(os.Stdout, "Create on-call schedule\n")
6063
fmt.Fprintf(os.Stdout, "\nUsage\n")
@@ -66,7 +69,7 @@ func RunCreateShiftPlan(arguments []string) error {
6669

6770
if err := createCommand.Parse(arguments); err != nil {
6871
createCommand.Usage()
69-
return fmt.Errorf("could not parse CLI arguments: %w", err)
72+
return fmt.Errorf("could not readTeamFile CLI arguments: %w", err)
7073
}
7174

7275
if showHelp {
@@ -75,34 +78,23 @@ func RunCreateShiftPlan(arguments []string) error {
7578
}
7679

7780
// check that the required flags are set...
78-
if !cli.IsFlagPassed(createCommand, "start") {
81+
if ok, missed := cli.RequiredFlagPassed(createCommand, "start", "end", "team-file"); !ok {
7982
createCommand.Usage()
80-
return fmt.Errorf("%w: %s", ErrMissingArguments, "start")
81-
}
82-
83-
if !cli.IsFlagPassed(createCommand, "end") {
84-
createCommand.Usage()
85-
return fmt.Errorf("%w: %s", ErrMissingArguments, "end")
86-
}
87-
88-
if !cli.IsFlagPassed(createCommand, "team-file") {
89-
createCommand.Usage()
90-
return fmt.Errorf("%w: %s", ErrMissingArguments, "team-file")
91-
}
92-
93-
// validate input data...
94-
if str == end {
95-
createCommand.Usage()
96-
return ErrInvalidArgument
83+
return fmt.Errorf("%w: %s", ErrMissingArguments, strings.Join(missed, ","))
9784
}
9885

9986
// initialize and run...
100-
team, err := parse(*teamFilePath)
87+
team, err := readTeamFile(*teamFilePath)
10188
if err != nil {
10289
return fmt.Errorf("%w: invalid team config file: %w", ErrInvalidArgument, err)
10390
}
10491

105-
plan, err := shiftplan.NewDefaultShiftPlanner(team.Employees).Plan(*str, *end, time.Duration(*duration)*time.Hour)
92+
rules, err := readRules(*enabledRules)
93+
if err != nil {
94+
return fmt.Errorf("%w: invalid rules: %w", ErrInvalidArgument, err)
95+
}
96+
97+
plan, err := shiftplan.NewShiftPlanner(team.Employees, rules, rules).Plan(*start, *end, time.Duration(*duration)*time.Hour)
10698
if err != nil {
10799
return fmt.Errorf("can not create on-call schedule: %w", err)
108100
}
@@ -114,16 +106,37 @@ func RunCreateShiftPlan(arguments []string) error {
114106
return nil
115107
}
116108

117-
func parse(path string) (apis.Team, error) {
109+
func readTeamFile(path string) (apis.Team, error) {
118110
content, err := os.ReadFile(path)
119111
if err != nil {
120112
return apis.Team{}, fmt.Errorf("could not read file '%s': %w", path, err)
121113
}
122114

123115
var team apis.Team
124116
if err := json.Unmarshal(content, &team); err != nil {
125-
return apis.Team{}, fmt.Errorf("could not pars json file '%s': %w", path, err)
117+
return apis.Team{}, fmt.Errorf("could not parse json file '%s': %w", path, err)
126118
}
127119

128120
return team, nil
129121
}
122+
123+
func readRules(value string) ([]apis.Rule, error) {
124+
var rules []apis.Rule
125+
for _, s := range strings.Split(value, ",") {
126+
switch strings.ToLower(s) {
127+
case "vacation":
128+
rules = append(rules, shiftplan.NoVacationOverlap())
129+
case "minimumoneshiftgap":
130+
rules = append(rules, shiftplan.MinimumOneShiftGap())
131+
case "minimumtwoshiftgap":
132+
rules = append(rules, shiftplan.MinimumTwoShiftGap())
133+
case "minimumthreeshiftgap":
134+
rules = append(rules, shiftplan.MinimumThreeShiftGap())
135+
case "minimumfourshiftgap":
136+
rules = append(rules, shiftplan.MinimumFourShiftGap())
137+
default:
138+
return nil, fmt.Errorf("unknow rule: %s", s)
139+
}
140+
}
141+
return rules, nil
142+
}

internal/cli/flagset_helper.go

+14-7
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,21 @@ import (
1010
"time"
1111
)
1212

13-
func IsFlagPassed(f *flag.FlagSet, name string) bool {
14-
found := false
15-
f.Visit(func(f *flag.Flag) {
16-
if f.Name == name {
17-
found = true
18-
}
13+
func RequiredFlagPassed(f *flag.FlagSet, names ...string) (bool, []string) {
14+
var missedFlags []string
15+
visited := make(map[string]bool)
16+
17+
f.Visit(func(fl *flag.Flag) {
18+
visited[fl.Name] = true
1919
})
20-
return found
20+
21+
for _, name := range names {
22+
if !visited[name] {
23+
missedFlags = append(missedFlags, name)
24+
}
25+
}
26+
27+
return len(missedFlags) == 0, missedFlags
2128
}
2229

2330
func TimeValueVar(t *time.Time) func(s string) error {

internal/shiftplan/default_rules.go

+21-5
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import (
66
"github.com/orltom/on-call-schedule/pkg/apis"
77
)
88

9-
var _ apis.Rule = VacationConflict()
9+
var _ apis.Rule = NoVacationOverlap()
1010

11-
var _ apis.Rule = InvolvedInLastSift()
11+
var _ apis.Rule = MinimumOneShiftGap()
1212

1313
type DefaultRule struct {
1414
fn func(e apis.Employee, _ []apis.Shift, start time.Time, end time.Time) bool
@@ -18,7 +18,7 @@ func (d *DefaultRule) Match(employee apis.Employee, shifts []apis.Shift, start t
1818
return d.fn(employee, shifts, start, end)
1919
}
2020

21-
func VacationConflict() *DefaultRule {
21+
func NoVacationOverlap() *DefaultRule {
2222
return &DefaultRule{
2323
fn: func(e apis.Employee, _ []apis.Shift, start time.Time, end time.Time) bool {
2424
days := e.VacationDays
@@ -33,13 +33,29 @@ func VacationConflict() *DefaultRule {
3333
}
3434
}
3535

36-
func InvolvedInLastSift() *DefaultRule {
36+
func MinimumOneShiftGap() *DefaultRule {
37+
return minimumWeekGap(1)
38+
}
39+
40+
func MinimumTwoShiftGap() *DefaultRule {
41+
return minimumWeekGap(2)
42+
}
43+
44+
func MinimumThreeShiftGap() *DefaultRule {
45+
return minimumWeekGap(3)
46+
}
47+
48+
func MinimumFourShiftGap() *DefaultRule {
49+
return minimumWeekGap(4)
50+
}
51+
52+
func minimumWeekGap(gap int) *DefaultRule {
3753
return &DefaultRule{
3854
fn: func(e apis.Employee, shifts []apis.Shift, _ time.Time, _ time.Time) bool {
3955
if len(shifts) == 0 {
4056
return false
4157
}
42-
lastShift := shifts[len(shifts)-1]
58+
lastShift := shifts[len(shifts)-gap]
4359
if lastShift.Primary == e.ID || lastShift.Secondary == e.ID {
4460
return true
4561
}

internal/shiftplan/default_rules_test.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func TestVacationConflict(t *testing.T) {
4848
}
4949
for _, tt := range tests {
5050
t.Run(tt.name, func(t *testing.T) {
51-
d := VacationConflict()
51+
d := NoVacationOverlap()
5252
if got := d.Match(tt.args.employee, nil, tt.args.start, tt.args.end); got != tt.want {
5353
t.Errorf("Match() = %v, want %v", got, tt.want)
5454
}
@@ -110,7 +110,7 @@ func TestInvolvedInLastSift(t *testing.T) {
110110
}
111111
for _, tt := range tests {
112112
t.Run(tt.name, func(t *testing.T) {
113-
d := InvolvedInLastSift()
113+
d := MinimumOneShiftGap()
114114
if got := d.Match(tt.args.employee, tt.args.shifts, time.Now(), time.Now()); got != tt.want {
115115
t.Errorf("Match() = %v, want %v", got, tt.want)
116116
}

internal/shiftplan/planner.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ type ShiftPlanner struct {
1717
func NewDefaultShiftPlanner(team []apis.Employee) *ShiftPlanner {
1818
return NewShiftPlanner(
1919
team,
20-
[]apis.Rule{VacationConflict()},
21-
[]apis.Rule{VacationConflict()},
20+
[]apis.Rule{NoVacationOverlap()},
21+
[]apis.Rule{NoVacationOverlap()},
2222
)
2323
}
2424

0 commit comments

Comments
 (0)