This commit is contained in:
13
internal/utils/convert_name_format.go
Normal file
13
internal/utils/convert_name_format.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package utils
|
||||
|
||||
import "strings"
|
||||
|
||||
func ConvertNameFormat(name string) string {
|
||||
parts := strings.Split(name, ",")
|
||||
if len(parts) == 2 {
|
||||
lastName := strings.TrimSpace(parts[0])
|
||||
firstName := strings.TrimSpace(parts[1])
|
||||
return firstName + " " + lastName
|
||||
}
|
||||
return name
|
||||
}
|
||||
43
internal/utils/error.go
Normal file
43
internal/utils/error.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/swaggest/rest"
|
||||
)
|
||||
|
||||
// CustomApplicationError is a marker interface that tells error middleware to serialize this error as-is, using Json schema.
|
||||
type CustomApplicationError interface {
|
||||
rest.ErrWithHTTPStatus
|
||||
CustomApplicationError()
|
||||
}
|
||||
|
||||
type ApplicationError struct {
|
||||
Message string `json:"message"`
|
||||
code int
|
||||
}
|
||||
|
||||
var (
|
||||
_ CustomApplicationError = ApplicationError{}
|
||||
)
|
||||
|
||||
func NewApplicationError(message string, code int) ApplicationError {
|
||||
return ApplicationError{
|
||||
Message: message,
|
||||
code: code,
|
||||
}
|
||||
}
|
||||
|
||||
func (e ApplicationError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func (e ApplicationError) HTTPStatus() int {
|
||||
return e.code
|
||||
}
|
||||
|
||||
func (e ApplicationError) CustomApplicationError() {}
|
||||
|
||||
func ValidationError(message string) ApplicationError {
|
||||
return NewApplicationError(message, http.StatusUnprocessableEntity)
|
||||
}
|
||||
20
internal/utils/first_day_of_month.go
Normal file
20
internal/utils/first_day_of_month.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package utils
|
||||
|
||||
import "time"
|
||||
|
||||
func FirstDayOfMonth() string {
|
||||
// Load America/Detroit timezone
|
||||
loc, err := time.LoadLocation("America/Detroit")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Get current time in Detroit timezone
|
||||
now := time.Now().In(loc)
|
||||
|
||||
// Create midnight of the first day of the current month
|
||||
firstDay := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
|
||||
|
||||
// Return ISO8601 formatted string
|
||||
return firstDay.Format(time.RFC3339)
|
||||
}
|
||||
34
internal/utils/levenshtein_distance.go
Normal file
34
internal/utils/levenshtein_distance.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package utils
|
||||
|
||||
import "strings"
|
||||
|
||||
func LevenshteinDistance(s1, s2 string) int {
|
||||
s1 = strings.ToLower(s1)
|
||||
s2 = strings.ToLower(s2)
|
||||
|
||||
len1 := len(s1)
|
||||
len2 := len(s2)
|
||||
|
||||
matrix := make([][]int, len1+1)
|
||||
for i := range matrix {
|
||||
matrix[i] = make([]int, len2+1)
|
||||
matrix[i][0] = i
|
||||
}
|
||||
for j := range matrix[0] {
|
||||
matrix[0][j] = j
|
||||
}
|
||||
|
||||
for i := 1; i <= len1; i++ {
|
||||
for j := 1; j <= len2; j++ {
|
||||
cost := 0
|
||||
if s1[i-1] != s2[j-1] {
|
||||
cost = 1
|
||||
}
|
||||
matrix[i][j] = min(
|
||||
matrix[i-1][j]+1,
|
||||
min(matrix[i][j-1]+1, matrix[i-1][j-1]+cost),
|
||||
)
|
||||
}
|
||||
}
|
||||
return matrix[len1][len2]
|
||||
}
|
||||
19
internal/utils/logging.go
Normal file
19
internal/utils/logging.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func LogMessageFromDatabaseOperation(entityName string, operation string, dbOp func() error) (string, error) {
|
||||
err := dbOp()
|
||||
logMsg := fmt.Sprintf("%s %sd successfully", cases.Title(language.English).String(entityName), operation)
|
||||
|
||||
if err != nil {
|
||||
logMsg = fmt.Sprintf("%s %s encountered an error", cases.Title(language.English).String(operation), entityName)
|
||||
}
|
||||
|
||||
return logMsg, err
|
||||
}
|
||||
12
internal/utils/must.go
Normal file
12
internal/utils/must.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package utils
|
||||
|
||||
import "go.uber.org/zap"
|
||||
|
||||
func Must[T any](res T, err error) func(logger *zap.Logger) T {
|
||||
return func(logger *zap.Logger) T {
|
||||
if err != nil {
|
||||
logger.Fatal("Fatal error", zap.Error(err))
|
||||
}
|
||||
return res
|
||||
}
|
||||
}
|
||||
23
internal/utils/normalize_name.go
Normal file
23
internal/utils/normalize_name.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func NormalizeName(name string) string {
|
||||
// Remove extra whitespace
|
||||
name = strings.TrimSpace(name)
|
||||
name = regexp.MustCompile(`\s+`).ReplaceAllString(name, " ")
|
||||
|
||||
// Remove common punctuation but keep hyphens
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if unicode.IsLetter(r) || unicode.IsSpace(r) || r == '-' {
|
||||
return unicode.ToLower(r)
|
||||
}
|
||||
return -1
|
||||
}, name)
|
||||
|
||||
return name
|
||||
}
|
||||
105
internal/utils/parse_xls_file.go
Normal file
105
internal/utils/parse_xls_file.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type Shift struct {
|
||||
EarningCode string `json:"earning_code"`
|
||||
Description string `json:"description"`
|
||||
Hours float64 `json:"hours"`
|
||||
}
|
||||
|
||||
func (s Shift) CountsAsWorked() bool {
|
||||
return strings.ToLower(s.EarningCode) == "reg"
|
||||
}
|
||||
|
||||
type Employee struct {
|
||||
Name string `json:"name"`
|
||||
Shifts []Shift `json:"shifts"`
|
||||
}
|
||||
|
||||
func (e Employee) Worked() bool {
|
||||
return lo.ContainsBy(e.Shifts, func(s Shift) bool { return s.CountsAsWorked() })
|
||||
}
|
||||
|
||||
func ParseUploadedXLSFile(file *multipart.FileHeader) ([]Employee, error) {
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open XLS file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
content, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read XLS file: %w", err)
|
||||
}
|
||||
|
||||
return ParseXLSContent(content)
|
||||
}
|
||||
|
||||
// ParseXLSContent parses the HTML content of an XLS file and returns a slice
|
||||
// of employees with their shifts. The XLS files are HTML tables where:
|
||||
// - Column A: employee name (td with class="smallbold" and valign=top)
|
||||
// - Column B: inner table containing shift rows
|
||||
// - Within the inner table, rows with colspan are category headers (skipped)
|
||||
// - Shift rows have: spacer td, earning code/description td (class="smalltext"),
|
||||
// and hours td
|
||||
func ParseXLSContent(content []byte) ([]Employee, error) {
|
||||
html := string(content)
|
||||
|
||||
// Split by employee rows in the outer table. Each employee row contains
|
||||
// a name cell (column A) followed by a cell with an inner table (column B).
|
||||
employeePattern := regexp.MustCompile(
|
||||
`<td\s+valign=top\s+class="smallbold">([^<]+)</td>\s*<td>(.*?)</table></td>`,
|
||||
)
|
||||
employeeMatches := employeePattern.FindAllStringSubmatch(html, -1)
|
||||
|
||||
// Pattern for shift data rows: spacer td + earning code td + hours td
|
||||
// These are rows where column C has class="smalltext" (not a colspan header)
|
||||
shiftPattern := regexp.MustCompile(
|
||||
`<tr><td width=25></td><td class=smalltext>([^<]+)</td><td[^>]*>([^<]+)</td></tr>`,
|
||||
)
|
||||
|
||||
employees := lo.Map(employeeMatches, func(empMatch []string, _ int) Employee {
|
||||
name := strings.TrimSpace(empMatch[1])
|
||||
innerTable := empMatch[2]
|
||||
|
||||
shiftMatches := shiftPattern.FindAllStringSubmatch(innerTable, -1)
|
||||
|
||||
shifts := lo.FilterMap(shiftMatches, func(sm []string, _ int) (Shift, bool) {
|
||||
codeAndDesc := strings.TrimSpace(sm[1])
|
||||
hoursStr := strings.TrimSpace(sm[2])
|
||||
|
||||
parts := strings.SplitN(codeAndDesc, " - ", 2)
|
||||
if len(parts) != 2 {
|
||||
return Shift{}, false
|
||||
}
|
||||
|
||||
hours, err := strconv.ParseFloat(hoursStr, 64)
|
||||
if err != nil {
|
||||
return Shift{}, false
|
||||
}
|
||||
|
||||
return Shift{
|
||||
EarningCode: strings.TrimSpace(parts[0]),
|
||||
Description: strings.TrimSpace(parts[1]),
|
||||
Hours: hours,
|
||||
}, true
|
||||
})
|
||||
|
||||
return Employee{
|
||||
Name: name,
|
||||
Shifts: shifts,
|
||||
}
|
||||
})
|
||||
|
||||
return employees, nil
|
||||
}
|
||||
77
internal/utils/parse_xls_file_test.go
Normal file
77
internal/utils/parse_xls_file_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package utils_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"clintonambulance.com/calculate_negative_points/internal/utils"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ParseXLSContent", func() {
|
||||
It("parses employees and their shifts from the test XLS file", func() {
|
||||
content, err := os.ReadFile("testdata/test.xls")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
employees, err := utils.ParseXLSContent(content)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(employees).To(HaveLen(2))
|
||||
|
||||
By("parsing the first employee")
|
||||
Expect(employees[0].Name).To(Equal("Howe, Eugene"))
|
||||
Expect(employees[0].Worked()).To(BeTrue())
|
||||
Expect(employees[0].Shifts).To(HaveLen(2))
|
||||
Expect(employees[0].Shifts[0]).To(Equal(utils.Shift{
|
||||
EarningCode: "MEET",
|
||||
Description: "Meetings",
|
||||
Hours: 1.75,
|
||||
}))
|
||||
Expect(employees[0].Shifts[0].CountsAsWorked()).To(BeFalse())
|
||||
Expect(employees[0].Shifts[1]).To(Equal(utils.Shift{
|
||||
EarningCode: "Reg",
|
||||
Description: "Regular Hours",
|
||||
Hours: 125.75,
|
||||
}))
|
||||
Expect(employees[0].Shifts[1].CountsAsWorked()).To(BeTrue())
|
||||
|
||||
By("parsing the second employee with multiple shift categories")
|
||||
Expect(employees[1].Name).To(Equal("User, Test"))
|
||||
Expect(employees[1].Shifts).To(HaveLen(4))
|
||||
Expect(employees[1].Shifts[0]).To(Equal(utils.Shift{
|
||||
EarningCode: "MEET",
|
||||
Description: "Meetings",
|
||||
Hours: 2.00,
|
||||
}))
|
||||
Expect(employees[1].Shifts[1]).To(Equal(utils.Shift{
|
||||
EarningCode: "Reg",
|
||||
Description: "Regular Hours",
|
||||
Hours: 134.00,
|
||||
}))
|
||||
Expect(employees[1].Shifts[2]).To(Equal(utils.Shift{
|
||||
EarningCode: "Holiday",
|
||||
Description: "Holiday Hours",
|
||||
Hours: 8.00,
|
||||
}))
|
||||
Expect(employees[1].Shifts[3]).To(Equal(utils.Shift{
|
||||
EarningCode: "PTO",
|
||||
Description: "Paid Time Off",
|
||||
Hours: 12.00,
|
||||
}))
|
||||
})
|
||||
|
||||
It("skips category header rows (colspan rows)", func() {
|
||||
content, err := os.ReadFile("testdata/test.xls")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
employees, err := utils.ParseXLSContent(content)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Expect(employees).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("returns an empty slice for empty content", func() {
|
||||
employees, err := utils.ParseXLSContent([]byte(""))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(employees).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
14
internal/utils/random_string.go
Normal file
14
internal/utils/random_string.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func RandomString(n int) (string, error) {
|
||||
bytes := make([]byte, n)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
11
internal/utils/schema/openfeature_flag.go
Normal file
11
internal/utils/schema/openfeature_flag.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package schemautils
|
||||
|
||||
type OpenfeatureFlag struct {
|
||||
Name string `json:"name" required:"true" description:"Name of the flag"`
|
||||
Id string `json:"id" required:"true" description:"UUID of the flag"`
|
||||
Kind string `json:"kind" required:"true" description:"Flag kind" enum:"boolean,number,string,string_array,int_array,float_array"`
|
||||
Enabled bool `json:"enabled" required:"true" description:"This is hardcoded to true"`
|
||||
Variant string `json:"variant" required:"true" description:"The name of the variant that is assigned to the appclient environment"`
|
||||
Variants map[string]interface{} `json:"variants" required:"true" description:"All variants and their values" nullable:"false"`
|
||||
Default string `json:"default" required:"true" description:"Name of the default variant assigned to the flag"`
|
||||
}
|
||||
24
internal/utils/to_title_case.go
Normal file
24
internal/utils/to_title_case.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package utils
|
||||
|
||||
import "strings"
|
||||
|
||||
func ToTitleCase(name string) string {
|
||||
words := strings.Fields(name)
|
||||
for i, word := range words {
|
||||
if len(word) > 0 {
|
||||
// Handle hyphenated names
|
||||
if strings.Contains(word, "-") {
|
||||
parts := strings.Split(word, "-")
|
||||
for j, part := range parts {
|
||||
if len(part) > 0 {
|
||||
parts[j] = strings.ToUpper(string(part[0])) + strings.ToLower(part[1:])
|
||||
}
|
||||
}
|
||||
words[i] = strings.Join(parts, "-")
|
||||
} else {
|
||||
words[i] = strings.ToUpper(string(word[0])) + strings.ToLower(word[1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
13
internal/utils/utils_suite_test.go
Normal file
13
internal/utils/utils_suite_test.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package utils_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestUtils(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Utils Suite")
|
||||
}
|
||||
Reference in New Issue
Block a user