mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-06-09 06:14:20 +00:00
daeaf0c605
The CF→AWS data migration (closed 2026-05-16) is long done and the tooling isn't wired into any production path. Remove the one-shot binary, its support package, and the migration runbook. In live code, replace 'JS-parity' / 'same shape as JS' / 'cross-runtime KV migration' comments with the real, stable reason for each behavior (wire-format invariant, null-vs-zero distinction, CloudWatch alarm field name, etc.). 24 files touched across lolschedule, loldle, wordle, twentyq, trading, misc, util, server, metrics, ai, keylock. - delete cmd/migrate_cf_data/ - delete internal/migration/ - delete docs/cf-to-aws-migration-runbook.md
140 lines
4.3 KiB
Go
140 lines
4.3 KiB
Go
package lolschedule
|
||
|
||
import (
|
||
"fmt"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// ictOffset is the ICT (UTC+7) offset. All day boundaries in this module
|
||
// are anchored on ICT.
|
||
const ictOffset = 7 * time.Hour
|
||
|
||
// formatHint is the user-facing usage line appended to parse errors.
|
||
const formatHint = "Use dd-mm-yyyy, dd/mm/yyyy, or ddmmyyyy."
|
||
|
||
// IctLocation is the fixed-offset UTC+7 timezone.
|
||
var IctLocation = time.FixedZone("ICT", int(ictOffset/time.Second))
|
||
|
||
// parseDateResult is the outcome of ParseScheduleDate. Date is the start of
|
||
// the requested ICT day, expressed as a UTC instant.
|
||
type parseDateResult struct {
|
||
OK bool
|
||
Date time.Time
|
||
Error string
|
||
}
|
||
|
||
var digitsOnly = regexp.MustCompile(`^\d+$`)
|
||
|
||
// ictDayStartOf returns the start of the ICT calendar day containing now,
|
||
// expressed as a UTC instant.
|
||
func ictDayStartOf(now time.Time) time.Time {
|
||
ict := now.In(IctLocation)
|
||
dayStart := time.Date(ict.Year(), ict.Month(), ict.Day(), 0, 0, 0, 0, IctLocation)
|
||
return dayStart.UTC()
|
||
}
|
||
|
||
// ictWeekStartOf returns the start of the ICT calendar week (Monday 00:00 ICT)
|
||
// containing now, expressed as a UTC instant. Week boundary is ISO 8601:
|
||
// Monday is day 1, Sunday is day 7.
|
||
func ictWeekStartOf(now time.Time) time.Time {
|
||
day := ictDayStartOf(now).In(IctLocation)
|
||
// time.Weekday: Sunday=0, Monday=1, ..., Saturday=6.
|
||
// Days since Monday: Mon→0, Tue→1, ..., Sun→6.
|
||
daysFromMonday := (int(day.Weekday()) + 6) % 7
|
||
return day.AddDate(0, 0, -daysFromMonday).UTC()
|
||
}
|
||
|
||
// addDays returns date + days, preserving time-of-day.
|
||
func addDays(date time.Time, days int) time.Time {
|
||
return date.Add(time.Duration(days) * 24 * time.Hour)
|
||
}
|
||
|
||
// splitParts breaks the trimmed input into [dd, mm?, yyyy?] string parts.
|
||
// Accepts dash- or slash-separated values, or a 1/2/4/8-digit unbroken
|
||
// run (today, this-month, this-year, full ddmmyyyy).
|
||
func splitParts(trimmed string) ([]string, string) {
|
||
if strings.ContainsAny(trimmed, "-/") {
|
||
// Replace both delimiters with a single one, then split.
|
||
normalized := strings.ReplaceAll(trimmed, "/", "-")
|
||
parts := strings.Split(normalized, "-")
|
||
if len(parts) < 1 || len(parts) > 3 {
|
||
return nil, fmt.Sprintf(`Invalid date %q. %s`, trimmed, formatHint)
|
||
}
|
||
for _, p := range parts {
|
||
if p == "" || !digitsOnly.MatchString(p) {
|
||
return nil, fmt.Sprintf(`Invalid date %q. %s`, trimmed, formatHint)
|
||
}
|
||
}
|
||
return parts, ""
|
||
}
|
||
|
||
if !digitsOnly.MatchString(trimmed) {
|
||
return nil, fmt.Sprintf(`Invalid date %q. %s`, trimmed, formatHint)
|
||
}
|
||
switch len(trimmed) {
|
||
case 1, 2:
|
||
return []string{trimmed}, ""
|
||
case 4:
|
||
return []string{trimmed[:2], trimmed[2:]}, ""
|
||
case 8:
|
||
return []string{trimmed[:2], trimmed[2:4], trimmed[4:]}, ""
|
||
default:
|
||
return nil, fmt.Sprintf(`Invalid date %q. %s`, trimmed, formatHint)
|
||
}
|
||
}
|
||
|
||
// ParseScheduleDate parses a /lolschedule date argument. Empty input → today.
|
||
// Returns the start of the requested ICT day as a UTC instant.
|
||
func ParseScheduleDate(input string, now time.Time) parseDateResult {
|
||
trimmed := strings.TrimSpace(input)
|
||
if trimmed == "" {
|
||
return parseDateResult{OK: true, Date: ictDayStartOf(now)}
|
||
}
|
||
|
||
parts, errMsg := splitParts(trimmed)
|
||
if errMsg != "" {
|
||
return parseDateResult{Error: errMsg}
|
||
}
|
||
|
||
ictNow := now.In(IctLocation)
|
||
day, _ := strconv.Atoi(parts[0])
|
||
month := int(ictNow.Month())
|
||
year := ictNow.Year()
|
||
if len(parts) >= 2 {
|
||
month, _ = strconv.Atoi(parts[1])
|
||
}
|
||
if len(parts) >= 3 {
|
||
year, _ = strconv.Atoi(parts[2])
|
||
}
|
||
|
||
if day < 1 || day > 31 {
|
||
return parseDateResult{Error: fmt.Sprintf(`Invalid day %q — must be 1–31.`, parts[0])}
|
||
}
|
||
if month < 1 || month > 12 {
|
||
monthStr := ""
|
||
if len(parts) >= 2 {
|
||
monthStr = parts[1]
|
||
}
|
||
return parseDateResult{Error: fmt.Sprintf(`Invalid month %q — must be 1–12.`, monthStr)}
|
||
}
|
||
if year < 1970 || year > 2100 {
|
||
yearStr := ""
|
||
if len(parts) >= 3 {
|
||
yearStr = parts[2]
|
||
}
|
||
return parseDateResult{Error: fmt.Sprintf(`Invalid year %q.`, yearStr)}
|
||
}
|
||
|
||
// Build the ICT-midnight instant. time.Date normalises out-of-range days
|
||
// (e.g. April 31 → May 1) so we verify the round-trip below.
|
||
candidate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, IctLocation)
|
||
if candidate.Year() != year || int(candidate.Month()) != month || candidate.Day() != day {
|
||
return parseDateResult{Error: fmt.Sprintf(`Invalid date — %d/%d/%d does not exist.`, day, month, year)}
|
||
}
|
||
|
||
return parseDateResult{OK: true, Date: candidate.UTC()}
|
||
}
|