diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d3f8216 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,55 @@ +name: build + +on: + push: + branches: + - 'master' + tags: + - 'v*' + pull_request: + +jobs: + build: + strategy: + matrix: + go-version: [ ~1.16 ] + os: [ ubuntu-latest, macos-latest, windows-latest ] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go-version }} + - name: Cache Go modules + uses: actions/cache@v2 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: CI + run: make setup ci + - name: Upload coverage + uses: codecov/codecov-action@v2 + if: matrix.os == 'ubuntu-latest' + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: ./coverage.txt + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v2 + if: success() && startsWith(github.ref, 'refs/tags/') && matrix.os == 'ubuntu-latest' + with: + version: latest + distribution: goreleaser-pro + args: release --rm-dist + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} + TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_KEY }} + TWITTER_CONSUMER_SECRET: ${{ secrets.TWITTER_CONSUMER_SECRET }} + TWITTER_ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN }} + TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..1c634c4 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,22 @@ +name: golangci-lint +on: + push: + tags: + - v* + branches: + - master + - main + pull_request: +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@v2 + with: + go-version: ~1.16 + - uses: actions/checkout@v2 + - name: golangci-lint + uses: golangci/golangci-lint-action@v2 + with: + skip-go-installation: true diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..ff791f8 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,8 @@ +linters: + enable: + - thelper + - gofumpt + - tparallel + - unconvert + - unparam + - wastedassign diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..4688983 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,3 @@ +includes: + - from_url: + url: https://raw.githubusercontent.com/caarlos0/.goreleaserfiles/main/lib.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..969d14e --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# timea.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4493984 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/caarlos0/timea.go + +go 1.16 + +require github.com/matryer/is v1.4.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ddd6bbf --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= diff --git a/timea.go b/timea.go new file mode 100644 index 0000000..bdb19a8 --- /dev/null +++ b/timea.go @@ -0,0 +1,193 @@ +// Package timeago provides a simple library to format a time in a "time ago" manner. +package timeago + +import ( + "fmt" + "reflect" + "time" +) + +// Precision define the minimun amount of time to be considered. +type Precision uint + +const ( + // SecondPrecision is the second precision. + SecondPrecision Precision = iota + + // MinutePrecision is the minute precision. + MinutePrecision + + // HourPrecision is the hour precision. + HourPrecision + + // DayPrecision is the day precision. + DayPrecision + + // MonthPrecision is the month precision. + MonthPrecision + + // YearPrecision is the year precision. + YearPrecision +) + +// Options define the options of the library. +type Options struct { + Precision Precision + Format Format +} + +// Of returns the string representation of the given time with the given options. +func Of(t time.Time, options ...Options) string { + opt := Options{ + Precision: SecondPrecision, + Format: DefaultFormat, + } + + for _, o := range options { + if o.Precision != 0 { + opt.Precision = o.Precision + } + if !reflect.DeepEqual(o.Format, Format{}) { + opt.Format = o.Format + } + } + + switch opt.Precision { + case SecondPrecision: + seconds := time.Since(t).Round(time.Second).Seconds() + if seconds == 0 { + return opt.Format.ThisSecond + } + if seconds == 1 { + return opt.Format.LastSecond + } + if seconds < 60 { + return fmt.Sprintf(opt.Format.SecondsAgo, int(seconds)) + } + return Of(t, Options{ + Precision: MinutePrecision, + }) + case MinutePrecision: + minutes := time.Since(t).Round(time.Minute).Minutes() + if minutes == 0 { + return opt.Format.ThisMinute + } + if minutes == 1 { + return opt.Format.LastMinute + } + if minutes < 60 { + return fmt.Sprintf(opt.Format.MinutesAgo, int(minutes)) + } + return Of(t, Options{ + Precision: HourPrecision, + }) + case HourPrecision: + hours := time.Since(t).Round(time.Hour).Hours() + if hours == 0 { + return opt.Format.ThisHour + } + if hours == 1 { + return opt.Format.LastHour + } + if hours < 24 { + return fmt.Sprintf(opt.Format.HoursAgo, int(hours)) + } + return Of(t, Options{ + Precision: DayPrecision, + }) + case DayPrecision: + days := time.Since(t).Round(time.Hour*24).Hours() / 24 + if days == 0 { + return opt.Format.Today + } + if days == 1 { + return opt.Format.Yesterday + } + if days < 30 { + return fmt.Sprintf(opt.Format.DaysAgo, int(days)) + } + return Of(t, Options{ + Precision: MonthPrecision, + }) + case MonthPrecision: + months := time.Since(t).Round(time.Hour*24*30).Hours() / (24 * 30) + if months == 0 { + return opt.Format.ThisMonth + } + if months == 1 { + return opt.Format.LastMonth + } + if months < 12 { + return fmt.Sprintf(opt.Format.MonthsAgo, int(months)) + } + return Of(t, Options{ + Precision: YearPrecision, + }) + case YearPrecision: + years := time.Since(t).Round(time.Hour*24*365).Hours() / (24 * 365) + if years == 0 { + return opt.Format.ThisYear + } + if years == 1 { + return opt.Format.LastYear + } + return fmt.Sprintf(opt.Format.YearsAgo, int(years)) + } + + // this should never happen + return t.String() +} + +// Format is the format of the string returned by the library. +type Format struct { + ThisSecond string + LastSecond string + SecondsAgo string + + ThisMinute string + LastMinute string + MinutesAgo string + + ThisHour string + LastHour string + HoursAgo string + + Today string + Yesterday string + DaysAgo string + + ThisMonth string + LastMonth string + MonthsAgo string + + ThisYear string + LastYear string + YearsAgo string +} + +// DefaultFormat is the default format of the string returned by the library. +var DefaultFormat = Format{ + ThisSecond: "now", + LastSecond: "1 second ago", + SecondsAgo: "%d seconds ago", + + ThisMinute: "now", + LastMinute: "1 minute ago", + MinutesAgo: "%d minutes ago", + + ThisHour: "this hour", + LastHour: "last hour", + HoursAgo: "%d hours ago", + + Today: "today", + Yesterday: "yesterday", + DaysAgo: "%d days ago", + + ThisMonth: "this month", + LastMonth: "last month", + MonthsAgo: "%d months ago", + + ThisYear: "this year", + LastYear: "last year", + YearsAgo: "%d years ago", +} diff --git a/timea_test.go b/timea_test.go new file mode 100644 index 0000000..7c55011 --- /dev/null +++ b/timea_test.go @@ -0,0 +1,111 @@ +package timeago + +import ( + "fmt" + "testing" + "time" + + "github.com/matryer/is" +) + +func TestOfSecondPrecision(t *testing.T) { + for expected, input := range map[string]time.Time{ + "now": time.Now(), + "1 second ago": time.Now().Add(-1 * time.Second), + "2 seconds ago": time.Now().Add(-2 * time.Second), + "14 seconds ago": time.Now().Add(-14 * time.Second), + "1 minute ago": time.Now().Add(-63 * time.Second), + } { + t.Run(expected, func(t *testing.T) { + is.New(t).Equal(Of(input, Options{Precision: SecondPrecision}), expected) + }) + } +} + +func TestOfMinutePrecision(t *testing.T) { + for expected, input := range map[string]time.Time{ + "now": time.Now(), + "1 minute ago": time.Now().Add(-1 * time.Minute), + "2 minutes ago": time.Now().Add(-2 * time.Minute), + "14 minutes ago": time.Now().Add(-14 * time.Minute), + "last hour": time.Now().Add(-63 * time.Minute), + } { + t.Run(expected, func(t *testing.T) { + is.New(t).Equal(Of(input, Options{Precision: MinutePrecision}), expected) + }) + } +} + +func TestOfHourPrecision(t *testing.T) { + for expected, input := range map[string]time.Time{ + "this hour": time.Now(), + "last hour": time.Now().Add(-1 * time.Hour), + "2 hours ago": time.Now().Add(-2 * time.Hour), + "14 hours ago": time.Now().Add(-14 * time.Hour), + "yesterday": time.Now().Add(-25 * time.Hour), + } { + t.Run(expected, func(t *testing.T) { + is.New(t).Equal(Of(input, Options{Precision: HourPrecision}), expected) + }) + } +} + +func TestOfDayPrecision(t *testing.T) { + for expected, input := range map[string]time.Time{ + "today": time.Now(), + "yesterday": time.Now().Add(-25 * time.Hour), + "2 days ago": time.Now().Add(-38 * time.Hour), + "14 days ago": time.Now().Add(-14 * 24 * time.Hour), + "last month": time.Now().Add(-1 * 30 * 24 * time.Hour), + } { + t.Run(expected, func(t *testing.T) { + is.New(t).Equal(Of(input, Options{Precision: DayPrecision}), expected) + }) + } +} + +func TestOfMonthPrecision(t *testing.T) { + for expected, input := range map[string]time.Time{ + "this month": time.Now(), + "last month": time.Now().Add(-1 * 30 * 24 * time.Hour), + "3 months ago": time.Now().Add(-3 * 30 * 24 * time.Hour), + "last year": time.Now().Add(-1 * 365 * 24 * time.Hour), + } { + t.Run(expected, func(t *testing.T) { + is.New(t).Equal(Of(input, Options{Precision: MonthPrecision}), expected) + }) + } +} + +func TestOfYearPrecision(t *testing.T) { + for expected, input := range map[string]time.Time{ + "this year": time.Now(), + "last year": time.Now().Add(-1 * 365 * 24 * time.Hour), + "3 years ago": time.Now().Add(-3 * 365 * 24 * time.Hour), + } { + t.Run(expected, func(t *testing.T) { + is.New(t).Equal(Of(input, Options{Precision: YearPrecision}), expected) + }) + } +} + +func TestOfCustomFormat(t *testing.T) { + is.New(t).Equal( + Of( + time.Now(), + Options{ + Precision: MinutePrecision, + Format: Format{ + ThisMinute: "aloha", + }, + }, + ), + "aloha", + ) +} + +func ExampleOf() { + fmt.Println(Of(time.Now().Add(-10 * time.Second))) + // Output: + // 10 seconds ago +}