Skip to content

Commit ea3f22a

Browse files
committed
add new rules and assign them via cli flags
1 parent 7b82d8a commit ea3f22a

File tree

8 files changed

+117
-91
lines changed

8 files changed

+117
-91
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
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

Lines changed: 8 additions & 4 deletions
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

Lines changed: 52 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ package main
22

33
import (
44
"encoding/json"
5-
"errors"
65
"flag"
76
"fmt"
87
"os"
8+
"strings"
99
"time"
1010

1111
"github.com/orltom/on-call-schedule/internal/cli"
@@ -14,22 +14,17 @@ import (
1414
"github.com/orltom/on-call-schedule/pkg/apis"
1515
)
1616

17-
var (
18-
ErrMissingArguments = errors.New("missing required flag")
19-
ErrInvalidArgument = errors.New("invalid value")
20-
)
21-
22-
type Converter int
17+
type Format int
2318

2419
const (
25-
CVS Converter = iota
20+
CVS Format = iota
2621
Table
2722
JSON
2823
)
2924

3025
func RunCreateShiftPlan(arguments []string) error {
31-
enums := map[string]Converter{"CVS": CVS, "Table": Table, "json": JSON}
32-
converters := map[Converter]func() apis.Exporter{
26+
enums := map[string]Format{"CVS": CVS, "Table": Table, "json": JSON}
27+
exporters := map[Format]func() apis.Exporter{
3328
Table: func() apis.Exporter {
3429
return export.NewTableExporter()
3530
},
@@ -41,20 +36,24 @@ func RunCreateShiftPlan(arguments []string) error {
4136
},
4237
}
4338

44-
str := new(time.Time)
39+
start := new(time.Time)
4540
end := new(time.Time)
41+
primaryRules := new(string)
42+
secondaryRules := new(string)
4643
duration := new(int)
4744
teamFilePath := new(string)
48-
transform := Table
45+
outputFormat := Table
4946
var showHelp bool
5047

5148
createCommand := flag.NewFlagSet("create", flag.ExitOnError)
5249
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))
50+
createCommand.IntVar(duration, "duration", 7*24, "shift duration in hours")
51+
createCommand.Func("start", "(required) start time of the schedule plan", cli.TimeValueVar(start))
5552
createCommand.Func("end", "(required) end time of the schedule plan", cli.TimeValueVar(end))
5653
createCommand.Func("team-file", "(required) path to the file that contain all on-call duties", cli.FilePathVar(teamFilePath))
57-
createCommand.Func("output", "output format. One of (cvs, table, json)", cli.EnumValueVar(enums, &transform))
54+
createCommand.Func("output", "output format. One of (cvs, table, json)", cli.EnumValueVar(enums, &outputFormat))
55+
createCommand.StringVar(primaryRules, "primary-rules", "vacation", "Rule to decide which employee should be on-call for the next shift")
56+
createCommand.StringVar(secondaryRules, "secondary-rules", "vacation", "Rule to decide which employee should be on-call for the next shift")
5857
createCommand.Usage = func() {
5958
fmt.Fprintf(os.Stdout, "Create on-call schedule\n")
6059
fmt.Fprintf(os.Stdout, "\nUsage\n")
@@ -74,56 +73,72 @@ func RunCreateShiftPlan(arguments []string) error {
7473
return nil
7574
}
7675

77-
// check that the required flags are set...
78-
if !cli.IsFlagPassed(createCommand, "start") {
79-
createCommand.Usage()
80-
return fmt.Errorf("%w: %s", ErrMissingArguments, "start")
81-
}
82-
83-
if !cli.IsFlagPassed(createCommand, "end") {
76+
// validate CLI arguments...
77+
if ok, missed := cli.RequiredFlagPassed(createCommand, "start", "end", "team-file"); !ok {
8478
createCommand.Usage()
85-
return fmt.Errorf("%w: %s", ErrMissingArguments, "end")
79+
return fmt.Errorf("missing required flag: %s", strings.Join(missed, ","))
8680
}
8781

88-
if !cli.IsFlagPassed(createCommand, "team-file") {
89-
createCommand.Usage()
90-
return fmt.Errorf("%w: %s", ErrMissingArguments, "team-file")
82+
// initialize...
83+
team, err := readTeamFile(*teamFilePath)
84+
if err != nil {
85+
return fmt.Errorf("could not read %s: %w", *teamFilePath, err)
9186
}
9287

93-
// validate input data...
94-
if str == end {
95-
createCommand.Usage()
96-
return ErrInvalidArgument
88+
pRules, err := mapToRules(*primaryRules)
89+
if err != nil {
90+
return fmt.Errorf("invalid rules: %w", err)
9791
}
9892

99-
// initialize and run...
100-
team, err := parse(*teamFilePath)
93+
sRules, err := mapToRules(*secondaryRules)
10194
if err != nil {
102-
return fmt.Errorf("%w: invalid team config file: %w", ErrInvalidArgument, err)
95+
return fmt.Errorf("invalid rules: %w", err)
10396
}
10497

105-
plan, err := shiftplan.NewDefaultShiftPlanner(team.Employees).Plan(*str, *end, time.Duration(*duration)*time.Hour)
98+
// run...
99+
plan, err := shiftplan.NewShiftPlanner(team.Employees, pRules, sRules).Plan(*start, *end, time.Duration(*duration)*time.Hour)
106100
if err != nil {
107101
return fmt.Errorf("can not create on-call schedule: %w", err)
108102
}
109103

110-
if err := converters[transform]().Write(plan, os.Stdout); err != nil {
104+
if err := exporters[outputFormat]().Write(plan, os.Stdout); err != nil {
111105
return fmt.Errorf("unexpecting error: %w", err)
112106
}
113107

114108
return nil
115109
}
116110

117-
func parse(path string) (apis.Team, error) {
111+
func readTeamFile(path string) (apis.Team, error) {
118112
content, err := os.ReadFile(path)
119113
if err != nil {
120114
return apis.Team{}, fmt.Errorf("could not read file '%s': %w", path, err)
121115
}
122116

123117
var team apis.Team
124118
if err := json.Unmarshal(content, &team); err != nil {
125-
return apis.Team{}, fmt.Errorf("could not pars json file '%s': %w", path, err)
119+
return apis.Team{}, fmt.Errorf("could not parse json file '%s': %w", path, err)
126120
}
127121

128122
return team, nil
129123
}
124+
125+
func mapToRules(value string) ([]apis.Rule, error) {
126+
var rules []apis.Rule
127+
for _, s := range strings.Split(value, ",") {
128+
switch strings.ToLower(s) {
129+
case "vacation":
130+
rules = append(rules, shiftplan.NewNoVacationOverlap())
131+
case "minimumoneshiftgap":
132+
rules = append(rules, shiftplan.NewMinimumGapBetweenShifts(1))
133+
case "minimumtwoshiftgap":
134+
rules = append(rules, shiftplan.NewMinimumGapBetweenShifts(2))
135+
case "minimumthreeshiftgap":
136+
rules = append(rules, shiftplan.NewMinimumGapBetweenShifts(3))
137+
case "minimumfourshiftgap":
138+
rules = append(rules, shiftplan.NewMinimumGapBetweenShifts(4))
139+
default:
140+
return nil, fmt.Errorf("unknow rule: %s", s)
141+
}
142+
}
143+
return rules, nil
144+
}

internal/cli/flagset_helper.go

Lines changed: 14 additions & 7 deletions
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

Lines changed: 18 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,44 +6,28 @@ import (
66
"github.com/orltom/on-call-schedule/pkg/apis"
77
)
88

9-
var _ apis.Rule = VacationConflict()
10-
11-
var _ apis.Rule = InvolvedInLastSift()
12-
13-
type DefaultRule struct {
14-
fn func(e apis.Employee, _ []apis.Shift, start time.Time, end time.Time) bool
15-
}
16-
17-
func (d *DefaultRule) Match(employee apis.Employee, shifts []apis.Shift, start time.Time, end time.Time) bool {
18-
return d.fn(employee, shifts, start, end)
19-
}
20-
21-
func VacationConflict() *DefaultRule {
22-
return &DefaultRule{
23-
fn: func(e apis.Employee, _ []apis.Shift, start time.Time, end time.Time) bool {
24-
days := e.VacationDays
25-
for vIdx := range days {
26-
day := days[vIdx]
27-
if (day.After(start) && day.Before(end)) || day.Equal(start) || day.Equal(end) {
28-
return true
29-
}
9+
func NewNoVacationOverlap() apis.RuleFunc {
10+
return func(e apis.Employee, _ []apis.Shift, start time.Time, end time.Time) bool {
11+
days := e.VacationDays
12+
for vIdx := range days {
13+
day := days[vIdx]
14+
if (day.After(start) && day.Before(end)) || day.Equal(start) || day.Equal(end) {
15+
return true
3016
}
31-
return false
32-
},
17+
}
18+
return false
3319
}
3420
}
3521

36-
func InvolvedInLastSift() *DefaultRule {
37-
return &DefaultRule{
38-
fn: func(e apis.Employee, shifts []apis.Shift, _ time.Time, _ time.Time) bool {
39-
if len(shifts) == 0 {
40-
return false
41-
}
42-
lastShift := shifts[len(shifts)-1]
43-
if lastShift.Primary == e.ID || lastShift.Secondary == e.ID {
44-
return true
45-
}
22+
func NewMinimumGapBetweenShifts(gap int) apis.RuleFunc {
23+
return func(e apis.Employee, shifts []apis.Shift, _ time.Time, _ time.Time) bool {
24+
if len(shifts) == 0 {
4625
return false
47-
},
26+
}
27+
lastShift := shifts[len(shifts)-gap]
28+
if lastShift.Primary == e.ID || lastShift.Secondary == e.ID {
29+
return true
30+
}
31+
return false
4832
}
4933
}

internal/shiftplan/default_rules_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77
"github.com/orltom/on-call-schedule/pkg/apis"
88
)
99

10-
func TestVacationConflict(t *testing.T) {
10+
func TestNewNoVacationOverlap(t *testing.T) {
1111
type args struct {
1212
employee apis.Employee
1313
start time.Time
@@ -48,15 +48,15 @@ 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 := NewNoVacationOverlap()
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
}
5555
})
5656
}
5757
}
5858

59-
func TestInvolvedInLastSift(t *testing.T) {
59+
func TestNewMinimumWeekGap(t *testing.T) {
6060
type args struct {
6161
employee apis.Employee
6262
shifts []apis.Shift
@@ -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 := NewMinimumGapBetweenShifts(1)
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

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,25 @@ 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{NewNoVacationOverlap()},
21+
[]apis.Rule{NewNoVacationOverlap()},
2222
)
2323
}
2424

2525
func NewShiftPlanner(team []apis.Employee, primaryConflictCheckers []apis.Rule, secondaryConflictCheckers []apis.Rule) *ShiftPlanner {
26+
t := make([]apis.Employee, len(team))
27+
copy(t, team)
28+
29+
p := make([]apis.Rule, len(primaryConflictCheckers))
30+
copy(p, primaryConflictCheckers)
31+
32+
s := make([]apis.Rule, len(secondaryConflictCheckers))
33+
copy(s, secondaryConflictCheckers)
34+
2635
return &ShiftPlanner{
27-
team: team,
28-
primaryConflictCheckers: primaryConflictCheckers,
29-
secondaryConflictChecker: secondaryConflictCheckers,
36+
team: t,
37+
primaryConflictCheckers: p,
38+
secondaryConflictChecker: s,
3039
}
3140
}
3241

pkg/apis/plan_types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ type Rule interface {
1616
Match(employee Employee, shifts []Shift, start time.Time, end time.Time) bool
1717
}
1818

19+
type RuleFunc func(Employee, []Shift, time.Time, time.Time) bool
20+
21+
func (r RuleFunc) Match(employee Employee, shifts []Shift, start time.Time, end time.Time) bool {
22+
return r(employee, shifts, start, end)
23+
}
24+
1925
type Exporter interface {
2026
Write(plan []Shift, writer io.Writer) error
2127
}

0 commit comments

Comments
 (0)