Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
orltom committed Jan 3, 2025
1 parent 7b82d8a commit bb1e8e9
Show file tree
Hide file tree
Showing 7 changed files with 96 additions and 63 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ go.work.sum

# build
/ocs
/ocsctl
/demo.json
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@
[![License](https://img.shields.io/github/license/orltom/on-call-schedule)](/LICENSE)

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

## Usage
## Create on-call schedule plan


### Usage
Here is an example of how to create an on-call duty plan
```shell
> cat > demo.json << EOL
cat > demo.json << EOL
{
"employees": [
{"id": "joe@example.com", "name": "Joe"},
{"id": "jan@example.com", "name": "Jan", "vacationDays": ["2024-01-06","2024-01-07"]},
{"id": "lee@example.com", "name": "Lee"}
{"id": "lee@example.com", "name": "Lee"},
{"id": "eva@example.com", "name": "Eva"}
]
}
EOL
Expand Down
80 changes: 47 additions & 33 deletions cmd/create_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"flag"
"fmt"
"os"
"strings"
"time"

"github.com/orltom/on-call-schedule/internal/cli"
Expand All @@ -19,17 +20,17 @@ var (
ErrInvalidArgument = errors.New("invalid value")
)

type Converter int
type Format int

const (
CVS Converter = iota
CVS Format = iota
Table
JSON
)

func RunCreateShiftPlan(arguments []string) error {
enums := map[string]Converter{"CVS": CVS, "Table": Table, "json": JSON}
converters := map[Converter]func() apis.Exporter{
enums := map[string]Format{"CVS": CVS, "Table": Table, "json": JSON}
exporters := map[Format]func() apis.Exporter{
Table: func() apis.Exporter {
return export.NewTableExporter()
},
Expand All @@ -41,20 +42,22 @@ func RunCreateShiftPlan(arguments []string) error {
},
}

str := new(time.Time)
start := new(time.Time)
end := new(time.Time)
enabledRules := new(string)
duration := new(int)
teamFilePath := new(string)
transform := Table
outputFormat := Table
var showHelp bool

createCommand := flag.NewFlagSet("create", flag.ExitOnError)
createCommand.BoolVar(&showHelp, "h", false, "help for ocsctl create")
createCommand.IntVar(duration, "duration", 7*24, "shift duration in hours.")
createCommand.Func("start", "(required) start time of the schedule plan", cli.TimeValueVar(str))
createCommand.IntVar(duration, "duration", 7*24, "shift duration in hours")
createCommand.Func("start", "(required) start time of the schedule plan", cli.TimeValueVar(start))
createCommand.Func("end", "(required) end time of the schedule plan", cli.TimeValueVar(end))
createCommand.Func("team-file", "(required) path to the file that contain all on-call duties", cli.FilePathVar(teamFilePath))
createCommand.Func("output", "output format. One of (cvs, table, json)", cli.EnumValueVar(enums, &transform))
createCommand.Func("output", "output format. One of (cvs, table, json)", cli.EnumValueVar(enums, &outputFormat))
createCommand.StringVar(enabledRules, "enabled-rules", "vacation", "Rule to decide which employee should be on-call for the next shift")
createCommand.Usage = func() {
fmt.Fprintf(os.Stdout, "Create on-call schedule\n")
fmt.Fprintf(os.Stdout, "\nUsage\n")
Expand All @@ -74,56 +77,67 @@ func RunCreateShiftPlan(arguments []string) error {
return nil
}

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

if !cli.IsFlagPassed(createCommand, "end") {
createCommand.Usage()
return fmt.Errorf("%w: %s", ErrMissingArguments, "end")
}

if !cli.IsFlagPassed(createCommand, "team-file") {
createCommand.Usage()
return fmt.Errorf("%w: %s", ErrMissingArguments, "team-file")
}

// validate input data...
if str == end {
createCommand.Usage()
return ErrInvalidArgument
// initialize...
team, err := readTeamFile(*teamFilePath)
if err != nil {
return fmt.Errorf("%w: could not read %s: %w", ErrInvalidArgument, *teamFilePath, err)
}

// initialize and run...
team, err := parse(*teamFilePath)
rules, err := readRules(*enabledRules)
if err != nil {
return fmt.Errorf("%w: invalid team config file: %w", ErrInvalidArgument, err)
return fmt.Errorf("%w: invalid rules: %w", ErrInvalidArgument, err)
}

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

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

return nil
}

func parse(path string) (apis.Team, error) {
func readTeamFile(path string) (apis.Team, error) {
content, err := os.ReadFile(path)
if err != nil {
return apis.Team{}, fmt.Errorf("could not read file '%s': %w", path, err)
}

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

return team, nil
}

func readRules(value string) ([]apis.Rule, error) {
var rules []apis.Rule
for _, s := range strings.Split(value, ",") {
switch strings.ToLower(s) {
case "vacation":
rules = append(rules, shiftplan.NewNoVacationOverlap())
case "minimumoneshiftgap":
rules = append(rules, shiftplan.NewMinimumWeekGap(1))
case "minimumtwoshiftgap":
rules = append(rules, shiftplan.NewMinimumWeekGap(2))
case "minimumthreeshiftgap":
rules = append(rules, shiftplan.NewMinimumWeekGap(3))
case "minimumfourshiftgap":
rules = append(rules, shiftplan.NewMinimumWeekGap(4))
default:
return nil, fmt.Errorf("unknow rule: %s", s)
}
}
return rules, nil
}
21 changes: 14 additions & 7 deletions internal/cli/flagset_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,21 @@ import (
"time"
)

func IsFlagPassed(f *flag.FlagSet, name string) bool {
found := false
f.Visit(func(f *flag.Flag) {
if f.Name == name {
found = true
}
func RequiredFlagPassed(f *flag.FlagSet, names ...string) (bool, []string) {
var missedFlags []string
visited := make(map[string]bool)

f.Visit(func(fl *flag.Flag) {
visited[fl.Name] = true
})
return found

for _, name := range names {
if !visited[name] {
missedFlags = append(missedFlags, name)
}
}

return len(missedFlags) == 0, missedFlags
}

func TimeValueVar(t *time.Time) func(s string) error {
Expand Down
18 changes: 8 additions & 10 deletions internal/shiftplan/default_rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,13 @@ import (
"github.com/orltom/on-call-schedule/pkg/apis"
)

var _ apis.Rule = VacationConflict()

var _ apis.Rule = InvolvedInLastSift()
var _ apis.Rule = &DefaultRule{}

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

func (d *DefaultRule) Match(employee apis.Employee, shifts []apis.Shift, start time.Time, end time.Time) bool {
return d.fn(employee, shifts, start, end)
}

func VacationConflict() *DefaultRule {
func NewNoVacationOverlap() *DefaultRule {
return &DefaultRule{
fn: func(e apis.Employee, _ []apis.Shift, start time.Time, end time.Time) bool {
days := e.VacationDays
Expand All @@ -33,17 +27,21 @@ func VacationConflict() *DefaultRule {
}
}

func InvolvedInLastSift() *DefaultRule {
func NewMinimumWeekGap(gap int) *DefaultRule {
return &DefaultRule{
fn: func(e apis.Employee, shifts []apis.Shift, _ time.Time, _ time.Time) bool {
if len(shifts) == 0 {
return false
}
lastShift := shifts[len(shifts)-1]
lastShift := shifts[len(shifts)-gap]
if lastShift.Primary == e.ID || lastShift.Secondary == e.ID {
return true
}
return false
},
}
}

func (d *DefaultRule) Match(employee apis.Employee, shifts []apis.Shift, start time.Time, end time.Time) bool {
return d.fn(employee, shifts, start, end)
}
8 changes: 4 additions & 4 deletions internal/shiftplan/default_rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"github.com/orltom/on-call-schedule/pkg/apis"
)

func TestVacationConflict(t *testing.T) {
func TestNewNoVacationOverlap(t *testing.T) {
type args struct {
employee apis.Employee
start time.Time
Expand Down Expand Up @@ -48,15 +48,15 @@ func TestVacationConflict(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := VacationConflict()
d := NewNoVacationOverlap()
if got := d.Match(tt.args.employee, nil, tt.args.start, tt.args.end); got != tt.want {
t.Errorf("Match() = %v, want %v", got, tt.want)
}
})
}
}

func TestInvolvedInLastSift(t *testing.T) {
func TestNewMinimumWeekGap(t *testing.T) {
type args struct {
employee apis.Employee
shifts []apis.Shift
Expand Down Expand Up @@ -110,7 +110,7 @@ func TestInvolvedInLastSift(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := InvolvedInLastSift()
d := NewMinimumWeekGap(1)
if got := d.Match(tt.args.employee, tt.args.shifts, time.Now(), time.Now()); got != tt.want {
t.Errorf("Match() = %v, want %v", got, tt.want)
}
Expand Down
19 changes: 14 additions & 5 deletions internal/shiftplan/planner.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,25 @@ type ShiftPlanner struct {
func NewDefaultShiftPlanner(team []apis.Employee) *ShiftPlanner {
return NewShiftPlanner(
team,
[]apis.Rule{VacationConflict()},
[]apis.Rule{VacationConflict()},
[]apis.Rule{NewNoVacationOverlap()},
[]apis.Rule{NewNoVacationOverlap()},
)
}

func NewShiftPlanner(team []apis.Employee, primaryConflictCheckers []apis.Rule, secondaryConflictCheckers []apis.Rule) *ShiftPlanner {
t := make([]apis.Employee, len(team))
copy(t, team)

p := make([]apis.Rule, len(primaryConflictCheckers))
copy(p, primaryConflictCheckers)

s := make([]apis.Rule, len(secondaryConflictCheckers))
copy(s, secondaryConflictCheckers)

return &ShiftPlanner{
team: team,
primaryConflictCheckers: primaryConflictCheckers,
secondaryConflictChecker: secondaryConflictCheckers,
team: t,
primaryConflictCheckers: p,
secondaryConflictChecker: s,
}
}

Expand Down

0 comments on commit bb1e8e9

Please sign in to comment.