7 Commits

Author SHA1 Message Date
4651fcf571 Merge pull request #5 from taigrr/cd/godoc-tests-readme
docs: add godoc comments, expand README, improve test coverage
2026-04-14 21:23:34 -04:00
5d0a2d0cf4 docs: add godoc comments, expand README, add unit tests for GetDuration and ValidTimePeriods 2026-04-06 10:09:26 +00:00
34a9fe93f6 Merge pull request #4 from taigrr/cd/lint-fixes
fix(msp): resolve staticcheck warning and fix typo
2026-03-05 03:24:13 -05:00
523d062b04 fix(msp): resolve unused error value and fix typo
- Fix SA4006 staticcheck warning: unused err from GetDuration
- Fix typo: 'addtion' -> 'addition' in comment
2026-03-05 08:01:11 +00:00
f3bf5c7d6b Merge pull request #3 from taigrr/cd/modernize-and-cleanup
chore: bump Go 1.18→1.26, fix typos, clean up tests
2026-02-22 23:57:45 -05:00
8b431b5d2f chore: bump Go 1.18→1.26, fix typos, clean up tests
- Update go.mod to Go 1.26
- Fix help text typos ('menut' → 'menu', 'most-significant' → 'most-specific')
- Fix error comment typo ('ll' → 'all')
- Remove unnecessary fmt.Sprintf in test names
- Remove unused fmt imports from test files
- Add compile-time interface check for TimeWindow
2026-02-22 21:01:23 +00:00
433d28a87b add sponsor button 2022-10-22 20:47:21 -07:00
13 changed files with 299 additions and 29 deletions

12
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
# These are supported funding model platforms
github: taigrr # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -1,4 +1,4 @@
Copyright (C) 2021-2022 by Tai Groot <tai@taigrr.com> Copyright (C) 2021-2026 by Tai Groot <tai@taigrr.com>
Permission to use, copy, modify, and/or distribute this software for any Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted. purpose with or without fee is hereby granted.

103
README.md
View File

@@ -1,21 +1,104 @@
# Most Specific Period # Most Specific Period
An MSP, or Most Specific Period is defined as follows: [![Go Reference](https://pkg.go.dev/badge/github.com/taigrr/most-specific-period.svg)](https://pkg.go.dev/github.com/taigrr/most-specific-period)
- Given a period that hasnt started or has already finished, an error is returned. [![Go Report Card](https://goreportcard.com/badge/github.com/taigrr/most-specific-period)](https://goreportcard.com/report/github.com/taigrr/most-specific-period)
- Given a period that is currently valid, the period which is valid is chosen.
- Given two valid periods, one which is a month long and one which is a week long and completely overlap, the week-long period is chosen.
- Given two valid periods, one which is a week long and one which is a month long, but only overlap by a day (in either direction) the week-long period is selected.
- Given two valid periods, each exactly 30 days long, but offset by a week, the second (newer) period is given precedence.
- Given two valid periods with exact same start and end time, the period with the lowest-ranking lexicographic identifier is returned (i.e. period B has precedence over period A, as B comes after A lexicographically.) This is because the default behavior is to choose the newer period, and named periods are often in lexicographical order (increasing numbers, letters, etc.)
This library operates off Period interfaces, which contain the following: A Go library for selecting the narrowest time period containing a given
timestamp from a set of potentially overlapping periods.
## Installation
```bash
go get github.com/taigrr/most-specific-period
``` ```
## What is a Most Specific Period?
Given overlapping time periods, the MSP algorithm picks the most precise one:
- Given a single valid period containing the timestamp, that period is chosen.
- Given two overlapping periods of different lengths, the shorter one wins.
- Given two periods of equal length, the one that started more recently wins.
- Given two periods with the same duration and start time, the lexicographically
last identifier wins (e.g. "B" over "A").
- Periods that haven't started yet or have already ended are ignored.
## Usage
Implement the `Period` interface or use the built-in `TimeWindow`:
```go
package main
import (
"fmt"
"time"
"github.com/taigrr/most-specific-period/msp"
)
func main() {
now := time.Now()
periods := []msp.Period{
msp.TimeWindow{
StartTime: now.Add(-24 * time.Hour),
EndTime: now.Add(24 * time.Hour),
Identifier: "this-week",
},
msp.TimeWindow{
StartTime: now.Add(-1 * time.Hour),
EndTime: now.Add(1 * time.Hour),
Identifier: "this-morning",
},
}
id, err := msp.MostSpecificPeriod(now, periods...)
if err != nil {
fmt.Println("No matching period")
return
}
fmt.Printf("MSP: %s\n", id) // "this-morning"
}
```
### Period Interface
```go
type Period interface { type Period interface {
GetStartTime() time.Time // Inclusive start time GetStartTime() time.Time // Inclusive start time
GetEndTime() time.Time // Exclusive end time ("expiration time") GetEndTime() time.Time // Exclusive end time
GetIdentifier() string GetIdentifier() string
} }
``` ```
An example program is available to observe the operation and usage of this library. ### Additional Functions
- `GenerateTimeline(periods...)` — Flatten overlapping periods into a
non-overlapping timeline.
- `GetChangeOvers(periods...)` — Get timestamps where the MSP changes.
- `GetNextChangeOver(t, periods...)` — Get the next changeover after time `t`.
- `FlattenPeriods(periods...)` — Get ordered identifiers at each changeover.
- `ValidTimePeriods(ts, periods...)` — Filter periods valid at timestamp `ts`.
- `GetDuration(start, end)` — Calculate duration between two times.
## CLI
A demo CLI is included. It reads periods from stdin (one per three lines:
identifier, start time, end time in RFC 3339) and displays the timeline
and MSP.
```bash
go run . -d 2024-06-15T12:00:00Z <<EOF
summer
2024-06-01T00:00:00Z
2024-09-01T00:00:00Z
june
2024-06-01T00:00:00Z
2024-07-01T00:00:00Z
EOF
```
## License
0BSD — See [LICENSE](LICENSE) for details.

2
go.mod
View File

@@ -1,3 +1,3 @@
module github.com/taigrr/most-specific-period module github.com/taigrr/most-specific-period
go 1.18 go 1.26

View File

@@ -41,7 +41,7 @@ func warnMessage() {
} }
func helpMessage() { func helpMessage() {
fmt.Print("\nmost-significant-period [-h][-d]\n\nGenerates a timeline of periods and will provide a most significant period if available.\n\n-h\tShows this help menut\n-d\tProvide a RFC 3339 time to provide an alternate point for calculating MSP.") fmt.Print("\nmost-specific-period [-h][-d]\n\nGenerates a timeline of periods and will provide a most specific period if available.\n\n-h\tShows this help menu\n-d\tProvide an RFC 3339 time to provide an alternate point for calculating MSP.")
} }
func main() { func main() {

View File

@@ -5,6 +5,8 @@ import (
"time" "time"
) )
// GetChangeOvers returns the sorted list of timestamps where the most
// specific period changes from one identifier to another.
func GetChangeOvers(periods ...Period) (changeovers []time.Time) { func GetChangeOvers(periods ...Period) (changeovers []time.Time) {
timeStamps := []time.Time{} timeStamps := []time.Time{}
for _, x := range periods { for _, x := range periods {
@@ -37,6 +39,8 @@ func GetChangeOvers(periods ...Period) (changeovers []time.Time) {
return return
} }
// GetNextChangeOver returns the first changeover timestamp strictly after t.
// If no such changeover exists, ErrNoNextChangeover is returned.
func GetNextChangeOver(t time.Time, periods ...Period) (ts time.Time, err error) { func GetNextChangeOver(t time.Time, periods ...Period) (ts time.Time, err error) {
changeOvers := GetChangeOvers(periods...) changeOvers := GetChangeOvers(periods...)
for _, ts := range changeOvers { for _, ts := range changeOvers {
@@ -47,6 +51,8 @@ func GetNextChangeOver(t time.Time, periods ...Period) (ts time.Time, err error)
return time.Time{}, ErrNoNextChangeover return time.Time{}, ErrNoNextChangeover
} }
// FlattenPeriods returns an ordered list of period identifiers representing
// the most specific period at each changeover point.
func FlattenPeriods(periods ...Period) (ids []string) { func FlattenPeriods(periods ...Period) (ids []string) {
changeovers := GetChangeOvers(periods...) changeovers := GetChangeOvers(periods...)
for _, c := range changeovers { for _, c := range changeovers {

View File

@@ -1,7 +1,6 @@
package msp package msp
import ( import (
"fmt"
"testing" "testing"
"time" "time"
) )
@@ -148,7 +147,7 @@ func TestGetChangeOvers(t *testing.T) {
}, },
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(fmt.Sprintf("%s", tc.testID), func(t *testing.T) { t.Run(tc.testID, func(t *testing.T) {
changeovers := GetChangeOvers(tc.periods...) changeovers := GetChangeOvers(tc.periods...)
if !slicesEqual(changeovers, tc.result) { if !slicesEqual(changeovers, tc.result) {
t.Errorf("Expected %v but got %v", tc.result, changeovers) t.Errorf("Expected %v but got %v", tc.result, changeovers)
@@ -320,7 +319,7 @@ func TestFlattenPeriods(t *testing.T) {
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(fmt.Sprintf("%s", tc.testID), func(t *testing.T) { t.Run(tc.testID, func(t *testing.T) {
changeovers := FlattenPeriods(tc.periods...) changeovers := FlattenPeriods(tc.periods...)
if !slicesEqual(changeovers, tc.result) { if !slicesEqual(changeovers, tc.result) {
t.Errorf("Expected %v but got %v", tc.result, changeovers) t.Errorf("Expected %v but got %v", tc.result, changeovers)
@@ -468,7 +467,7 @@ func TestGetNextChangeOver(t *testing.T) {
}, },
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(fmt.Sprintf("%s", tc.testID), func(t *testing.T) { t.Run(tc.testID, func(t *testing.T) {
ts, err := GetNextChangeOver(now, tc.periods...) ts, err := GetNextChangeOver(now, tc.periods...)
if tc.err != err { if tc.err != err {
t.Errorf("Error %v does not match expected %v", tc.err, err) t.Errorf("Error %v does not match expected %v", tc.err, err)

View File

@@ -5,9 +5,9 @@ import (
) )
var ( var (
// ErrEndAfterStart occurs when a period given has an end time after its start time // ErrEndAfterStart occurs when a period's start time is after its end time
ErrEndAfterStart = errors.New("error: start time is after end time") ErrEndAfterStart = errors.New("error: start time is after end time")
// ErrNoValidPeriods occurs when an empty set of periods is passed or when ll periods are invalid // ErrNoValidPeriods occurs when an empty set of periods is passed or when all periods are invalid
ErrNoValidPeriods = errors.New("error: no valid periods available") ErrNoValidPeriods = errors.New("error: no valid periods available")
// ErrNoNextChangeover occurs when GetNextChangeover is called but there are no changeovers after t // ErrNoNextChangeover occurs when GetNextChangeover is called but there are no changeovers after t
ErrNoNextChangeover = errors.New("error: no valid changeovers available") ErrNoNextChangeover = errors.New("error: no valid changeovers available")

View File

@@ -5,6 +5,10 @@ import (
"time" "time"
) )
// MostSpecificPeriod returns the identifier of the shortest-duration period
// that contains timestamp ts. When multiple periods share the shortest
// duration, the one with the latest start time wins; if start times also
// match, the lexicographically last identifier is returned.
func MostSpecificPeriod(ts time.Time, periods ...Period) (id string, err error) { func MostSpecificPeriod(ts time.Time, periods ...Period) (id string, err error) {
// Filter to get only valid periods here // Filter to get only valid periods here
periods = ValidTimePeriods(ts, periods...) periods = ValidTimePeriods(ts, periods...)
@@ -12,7 +16,7 @@ func MostSpecificPeriod(ts time.Time, periods ...Period) (id string, err error)
return "", ErrNoValidPeriods return "", ErrNoValidPeriods
} }
// find the shortest duration // find the shortest duration
d, err := GetDuration(periods[0].GetStartTime(), periods[0].GetEndTime()) d, _ := GetDuration(periods[0].GetStartTime(), periods[0].GetEndTime())
for _, x := range periods { for _, x := range periods {
p, err := GetDuration(x.GetStartTime(), x.GetEndTime()) p, err := GetDuration(x.GetStartTime(), x.GetEndTime())
if err == nil && p < d { if err == nil && p < d {
@@ -34,7 +38,7 @@ func MostSpecificPeriod(ts time.Time, periods ...Period) (id string, err error)
newest = x.GetStartTime() newest = x.GetStartTime()
} }
} }
// Determine whichever of these periods have the same start time in addtion to duration // Determine whichever of these periods have the same start time in addition to duration
var matchingDurationsAndStartTimes []Period var matchingDurationsAndStartTimes []Period
for _, x := range matchingDurations { for _, x := range matchingDurations {
if x.GetStartTime() == newest { if x.GetStartTime() == newest {
@@ -50,6 +54,8 @@ func MostSpecificPeriod(ts time.Time, periods ...Period) (id string, err error)
return identifiers[len(identifiers)-1], nil return identifiers[len(identifiers)-1], nil
} }
// GetDuration returns the duration between start and end. If start is after
// end, ErrEndAfterStart is returned alongside the (negative) duration.
func GetDuration(start time.Time, end time.Time) (dur time.Duration, err error) { func GetDuration(start time.Time, end time.Time) (dur time.Duration, err error) {
if start.After(end) { if start.After(end) {
err = ErrEndAfterStart err = ErrEndAfterStart
@@ -58,6 +64,8 @@ func GetDuration(start time.Time, end time.Time) (dur time.Duration, err error)
return dur, err return dur, err
} }
// ValidTimePeriods filters periods to those whose start time is at or before
// ts and whose end time is strictly after ts.
func ValidTimePeriods(ts time.Time, periods ...Period) []Period { func ValidTimePeriods(ts time.Time, periods ...Period) []Period {
var valid []Period var valid []Period
for _, p := range periods { for _, p := range periods {

View File

@@ -1,12 +1,161 @@
package msp package msp
import ( import (
"fmt"
"testing" "testing"
"time" "time"
) )
// (periods ...Period) (id string, err error) { func TestGetDuration(t *testing.T) {
now := time.Now()
testCases := []struct {
testID string
start time.Time
end time.Time
dur time.Duration
err error
}{
{
testID: "Normal duration",
start: now,
end: now.Add(5 * time.Minute),
dur: 5 * time.Minute,
err: nil,
},
{
testID: "Zero duration",
start: now,
end: now,
dur: 0,
err: nil,
},
{
testID: "Start after end",
start: now.Add(5 * time.Minute),
end: now,
dur: -5 * time.Minute,
err: ErrEndAfterStart,
},
}
for _, tc := range testCases {
t.Run(tc.testID, func(t *testing.T) {
dur, err := GetDuration(tc.start, tc.end)
if dur != tc.dur {
t.Errorf("Duration %v does not match expected %v", dur, tc.dur)
}
if err != tc.err {
t.Errorf("Error %v does not match expected %v", err, tc.err)
}
})
}
}
func TestValidTimePeriods(t *testing.T) {
now := time.Now()
testCases := []struct {
testID string
ts time.Time
periods []Period
count int
}{
{
testID: "No periods",
ts: now,
periods: []Period{},
count: 0,
},
{
testID: "One valid period",
ts: now,
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
},
count: 1,
},
{
testID: "Period in the past",
ts: now,
periods: []Period{
TimeWindow{
StartTime: now.Add(-2 * time.Minute),
EndTime: now.Add(-time.Minute),
Identifier: "A",
},
},
count: 0,
},
{
testID: "Period in the future",
ts: now,
periods: []Period{
TimeWindow{
StartTime: now.Add(time.Minute),
EndTime: now.Add(2 * time.Minute),
Identifier: "A",
},
},
count: 0,
},
{
testID: "Timestamp equals start time (inclusive)",
ts: now,
periods: []Period{
TimeWindow{
StartTime: now,
EndTime: now.Add(time.Minute),
Identifier: "A",
},
},
count: 1,
},
{
testID: "Timestamp equals end time (exclusive)",
ts: now,
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now,
Identifier: "A",
},
},
count: 0,
},
{
testID: "Mixed valid and invalid",
ts: now,
periods: []Period{
TimeWindow{
StartTime: now.Add(-time.Minute),
EndTime: now.Add(time.Minute),
Identifier: "A",
},
TimeWindow{
StartTime: now.Add(-3 * time.Minute),
EndTime: now.Add(-2 * time.Minute),
Identifier: "B",
},
TimeWindow{
StartTime: now.Add(-5 * time.Minute),
EndTime: now.Add(5 * time.Minute),
Identifier: "C",
},
},
count: 2,
},
}
for _, tc := range testCases {
t.Run(tc.testID, func(t *testing.T) {
valid := ValidTimePeriods(tc.ts, tc.periods...)
if len(valid) != tc.count {
t.Errorf("Got %d valid periods, expected %d", len(valid), tc.count)
}
})
}
}
func TestMostSpecificPeriod(t *testing.T) { func TestMostSpecificPeriod(t *testing.T) {
// use a static timestamp to make sure tests don't fail on slower systems or during a process pause // use a static timestamp to make sure tests don't fail on slower systems or during a process pause
now := time.Now() now := time.Now()
@@ -146,7 +295,7 @@ func TestMostSpecificPeriod(t *testing.T) {
}, },
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(fmt.Sprintf("%s", tc.testID), func(t *testing.T) { t.Run(tc.testID, func(t *testing.T) {
id, err := MostSpecificPeriod(tc.ts, tc.periods...) id, err := MostSpecificPeriod(tc.ts, tc.periods...)
if id != tc.result { if id != tc.result {
t.Errorf("ID '%s' does not match expected '%s'", id, tc.result) t.Errorf("ID '%s' does not match expected '%s'", id, tc.result)

View File

@@ -5,24 +5,29 @@ import (
"time" "time"
) )
// TimeWindow is a concrete implementation of the Period interface.
type TimeWindow struct { type TimeWindow struct {
StartTime time.Time StartTime time.Time
EndTime time.Time EndTime time.Time
Identifier string Identifier string
} }
// GetIdentifier returns the period's identifier string.
func (p TimeWindow) GetIdentifier() string { func (p TimeWindow) GetIdentifier() string {
return p.Identifier return p.Identifier
} }
// GetEndTime returns the period's exclusive end time.
func (p TimeWindow) GetEndTime() time.Time { func (p TimeWindow) GetEndTime() time.Time {
return p.EndTime return p.EndTime
} }
// GetStartTime returns the period's inclusive start time.
func (p TimeWindow) GetStartTime() time.Time { func (p TimeWindow) GetStartTime() time.Time {
return p.StartTime return p.StartTime
} }
// String returns a tab-separated representation of the time window.
func (t TimeWindow) String() string { func (t TimeWindow) String() string {
return fmt.Sprintf("%s\t%s\t%s", return fmt.Sprintf("%s\t%s\t%s",
t.GetIdentifier(), t.GetIdentifier(),
@@ -30,7 +35,8 @@ func (t TimeWindow) String() string {
t.GetEndTime()) t.GetEndTime())
} }
// Outputs a formatted timeline of periods // GenerateTimeline produces a flattened timeline of non-overlapping periods
// by splitting overlapping input periods at changeover points.
func GenerateTimeline(periods ...Period) (out []Period) { func GenerateTimeline(periods ...Period) (out []Period) {
if len(periods) == 0 { if len(periods) == 0 {
return out return out

View File

@@ -263,7 +263,7 @@ func TestGenerateTime(t *testing.T) {
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(fmt.Sprintf("%s", tc.testID), func(t *testing.T) { t.Run(tc.testID, func(t *testing.T) {
timeline := GenerateTimeline(tc.periods...) timeline := GenerateTimeline(tc.periods...)
if len(timeline) != len(tc.result) { if len(timeline) != len(tc.result) {
t.Fatalf("Time line had %d results, expected %d", len(timeline), len(tc.result)) t.Fatalf("Time line had %d results, expected %d", len(timeline), len(tc.result))

View File

@@ -1,7 +1,14 @@
// Package msp implements the Most Specific Period algorithm, which selects
// the narrowest (shortest-duration) time period containing a given timestamp
// from a set of potentially overlapping periods.
package msp package msp
import "time" import "time"
// Compile-time interface check.
var _ Period = TimeWindow{}
// Period represents a named time window with inclusive start and exclusive end.
type Period interface { type Period interface {
GetStartTime() time.Time GetStartTime() time.Time
GetEndTime() time.Time GetEndTime() time.Time