104 lines
3.5 KiB
Go
104 lines
3.5 KiB
Go
package utils
|
||
|
||
import (
|
||
"fmt"
|
||
"time"
|
||
)
|
||
|
||
// Timestamps on the customer surface.
|
||
//
|
||
// The requirement is epoch milliseconds, UTC, integer — a real instant, not a
|
||
// wall clock. That is not what this database hands back. The DSN sets
|
||
// TimeZone=Asia/Kolkata and the timestamp columns hold IST wall-clock digits
|
||
// (see DBNow), so a stored value read into a time.Time carries the right
|
||
// digits with the wrong (or no) zone. Calling UnixMilli on it directly is off
|
||
// by 5h30m — the same class of defect as sending a naive local string with a Z
|
||
// on it, which has already produced "yesterday's work shown as today" on the
|
||
// miler app. So every timestamp leaving /customer/* goes through here.
|
||
//
|
||
// Reinterpreting the wall clock in IST is correct for both shapes this
|
||
// database produces: a value tagged UTC that actually holds IST digits, and a
|
||
// value correctly tagged +05:30. Both name the same instant afterwards.
|
||
|
||
// istLocation is Asia/Kolkata, with an exact fixed-offset fallback for
|
||
// containers shipped without tzdata. IST observes no DST, so +05:30 is not an
|
||
// approximation.
|
||
var istLocation = func() *time.Location {
|
||
if loc, err := time.LoadLocation("Asia/Kolkata"); err == nil {
|
||
return loc
|
||
}
|
||
return time.FixedZone("IST", 5*3600+30*60)
|
||
}()
|
||
|
||
// IST reinterprets a database timestamp's wall clock as Indian Standard Time,
|
||
// yielding the instant it actually names.
|
||
func IST(t time.Time) time.Time {
|
||
return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), istLocation)
|
||
}
|
||
|
||
// EpochMillis converts a database timestamp to UTC epoch milliseconds.
|
||
func EpochMillis(t time.Time) int64 {
|
||
return IST(t).UnixMilli()
|
||
}
|
||
|
||
// EpochMillisPtr is EpochMillis over a nullable column. A nil timestamp stays
|
||
// null in JSON rather than becoming the epoch, which the client would render
|
||
// as 1 Jan 1970.
|
||
func EpochMillisPtr(t *time.Time) *int64 {
|
||
if t == nil || t.IsZero() {
|
||
return nil
|
||
}
|
||
ms := EpochMillis(*t)
|
||
return &ms
|
||
}
|
||
|
||
// ISTNow is the current moment in IST, for formatting and for comparing
|
||
// against a value already put through IST().
|
||
func ISTNow() time.Time {
|
||
return time.Now().In(istLocation)
|
||
}
|
||
|
||
// ISTLocation exposes the zone for callers building their own times.
|
||
func ISTLocation() *time.Location { return istLocation }
|
||
|
||
// FormatISTDate renders a date the way the customer app shows it: "Thu, 12 Sep".
|
||
// Display strings are formatted server-side, in IST, so the client never has to
|
||
// know the operating timezone.
|
||
func FormatISTDate(t time.Time) string {
|
||
return IST(t).Format("Mon, 2 Jan")
|
||
}
|
||
|
||
// FormatISTDay renders a date relative to today where that reads better —
|
||
// "Today", "Tomorrow", otherwise "Mon, 8 Sep". Used for pickup slot days.
|
||
func FormatISTDay(t time.Time) string {
|
||
d := IST(t)
|
||
today := ISTNow()
|
||
y1, m1, d1 := d.Date()
|
||
y2, m2, d2 := today.Date()
|
||
switch {
|
||
case y1 == y2 && m1 == m2 && d1 == d2:
|
||
return "Today"
|
||
case y1 == y2 && m1 == m2 && d1 == d2+1:
|
||
return "Tomorrow"
|
||
default:
|
||
tomorrow := today.AddDate(0, 0, 1)
|
||
y3, m3, d3 := tomorrow.Date()
|
||
if y1 == y3 && m1 == m3 && d1 == d3 {
|
||
return "Tomorrow"
|
||
}
|
||
return d.Format("Mon, 2 Jan")
|
||
}
|
||
}
|
||
|
||
// FormatISTWindow renders a pickup window as the design writes it:
|
||
// "2:00 – 4:00 PM" — en dash, spaced, and the meridiem stated once when both
|
||
// ends share it.
|
||
func FormatISTWindow(from, to time.Time) string {
|
||
f, t := IST(from), IST(to)
|
||
fMer, tMer := f.Format("PM"), t.Format("PM")
|
||
if fMer == tMer {
|
||
return fmt.Sprintf("%s – %s", f.Format("3:04"), t.Format("3:04 PM"))
|
||
}
|
||
return fmt.Sprintf("%s – %s", f.Format("3:04 PM"), t.Format("3:04 PM"))
|
||
}
|