Skip to content

Commit bb1e8e9

Browse files
committed
wip
1 parent 7b82d8a commit bb1e8e9

File tree

7 files changed

+96
-63
lines changed

7 files changed

+96
-63
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: 47 additions & 33 deletions
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"
@@ -19,17 +20,17 @@ var (
1920
ErrInvalidArgument = errors.New("invalid value")
2021
)
2122

22-
type Converter int
23+
type Format int
2324

2425
const (
25-
CVS Converter = iota
26+
CVS Format = iota
2627
Table
2728
JSON
2829
)
2930

3031
func RunCreateShiftPlan(arguments []string) error {
31-
enums := map[string]Converter{"CVS": CVS, "Table": Table, "json": JSON}
32-
converters := map[Converter]func() apis.Exporter{
32+
enums := map[string]Format{"CVS": CVS, "Table": Table, "json": JSON}
33+
exporters := map[Format]func() apis.Exporter{
3334
Table: func() apis.Exporter {
3435
return export.NewTableExporter()
3536
},
@@ -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)
48-
transform := Table
50+
outputFormat := 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))
57-
createCommand.Func("output", "output format. One of (cvs, table, json)", cli.EnumValueVar(enums, &transform))
59+
createCommand.Func("output", "output format. One of (cvs, table, json)", cli.EnumValueVar(enums, &outputFormat))
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")
@@ -74,56 +77,67 @@ func RunCreateShiftPlan(arguments []string) error {
7477
return nil
7578
}
7679

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

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
86+
// initialize...
87+
team, err := readTeamFile(*teamFilePath)
88+
if err != nil {
89+
return fmt.Errorf("%w: could not read %s: %w", ErrInvalidArgument, *teamFilePath, err)
9790
}
9891

99-
// initialize and run...
100-
team, err := parse(*teamFilePath)
92+
rules, err := readRules(*enabledRules)
10193
if err != nil {
102-
return fmt.Errorf("%w: invalid team config file: %w", ErrInvalidArgument, err)
94+
return fmt.Errorf("%w: invalid rules: %w", ErrInvalidArgument, err)
10395
}
10496

105-
plan, err := shiftplan.NewDefaultShiftPlanner(team.Employees).Plan(*str, *end, time.Duration(*duration)*time.Hour)
97+
// run...
98+
plan, err := shiftplan.NewShiftPlanner(team.Employees, rules, rules).Plan(*start, *end, time.Duration(*duration)*time.Hour)
10699
if err != nil {
107100
return fmt.Errorf("can not create on-call schedule: %w", err)
108101
}
109102

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

114107
return nil
115108
}
116109

117-
func parse(path string) (apis.Team, error) {
110+
func readTeamFile(path string) (apis.Team, error) {
118111
content, err := os.ReadFile(path)
119112
if err != nil {
120113
return apis.Team{}, fmt.Errorf("could not read file '%s': %w", path, err)
121114
}
122115

123116
var team apis.Team
124117
if err := json.Unmarshal(content, &team); err != nil {
125-
return apis.Team{}, fmt.Errorf("could not pars json file '%s': %w", path, err)
118+
return apis.Team{}, fmt.Errorf("could not parse json file '%s': %w", path, err)
126119
}
127120

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

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: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,13 @@ import (
66
"github.com/orltom/on-call-schedule/pkg/apis"
77
)
88

9-
var _ apis.Rule = VacationConflict()
10-
11-
var _ apis.Rule = InvolvedInLastSift()
9+
var _ apis.Rule = &DefaultRule{}
1210

1311
type DefaultRule struct {
1412
fn func(e apis.Employee, _ []apis.Shift, start time.Time, end time.Time) bool
1513
}
1614

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 {
15+
func NewNoVacationOverlap() *DefaultRule {
2216
return &DefaultRule{
2317
fn: func(e apis.Employee, _ []apis.Shift, start time.Time, end time.Time) bool {
2418
days := e.VacationDays
@@ -33,17 +27,21 @@ func VacationConflict() *DefaultRule {
3327
}
3428
}
3529

36-
func InvolvedInLastSift() *DefaultRule {
30+
func NewMinimumWeekGap(gap int) *DefaultRule {
3731
return &DefaultRule{
3832
fn: func(e apis.Employee, shifts []apis.Shift, _ time.Time, _ time.Time) bool {
3933
if len(shifts) == 0 {
4034
return false
4135
}
42-
lastShift := shifts[len(shifts)-1]
36+
lastShift := shifts[len(shifts)-gap]
4337
if lastShift.Primary == e.ID || lastShift.Secondary == e.ID {
4438
return true
4539
}
4640
return false
4741
},
4842
}
4943
}
44+
45+
func (d *DefaultRule) Match(employee apis.Employee, shifts []apis.Shift, start time.Time, end time.Time) bool {
46+
return d.fn(employee, shifts, start, end)
47+
}

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 := NewMinimumWeekGap(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

0 commit comments

Comments
 (0)