feat: timezone-aware schedule with browser tz and JST client conversion
This commit is contained in:
@@ -33,6 +33,7 @@ type Entry struct {
|
|||||||
ImageURL string
|
ImageURL string
|
||||||
EpisodeText string
|
EpisodeText string
|
||||||
AirType AirType
|
AirType AirType
|
||||||
|
AirsAt time.Time
|
||||||
LocalTime string
|
LocalTime string
|
||||||
DateLabel string
|
DateLabel string
|
||||||
Weekday time.Weekday
|
Weekday time.Weekday
|
||||||
@@ -58,16 +59,28 @@ func (e *HTTPStatusError) Error() string {
|
|||||||
var reWeek = regexp.MustCompile(`(?i)[?&]week=(\d+)`)
|
var reWeek = regexp.MustCompile(`(?i)[?&]week=(\d+)`)
|
||||||
var reYear = regexp.MustCompile(`(?i)[?&]year=(\d+)`)
|
var reYear = regexp.MustCompile(`(?i)[?&]year=(\d+)`)
|
||||||
|
|
||||||
func scheduleLocation() *time.Location {
|
func scheduleLocation(timezone string) (*time.Location, error) {
|
||||||
// Use the host's local timezone (e.g. CEST) so the schedule matches the user's environment.
|
timezone = strings.TrimSpace(timezone)
|
||||||
return time.Local
|
if timezone == "" {
|
||||||
|
timezone = "UTC"
|
||||||
|
}
|
||||||
|
location, err := time.LoadLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load schedule timezone %q: %w", timezone, err)
|
||||||
|
}
|
||||||
|
return location, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int) (WeekSchedule, error) {
|
func FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int, timezone string) (WeekSchedule, error) {
|
||||||
apiToken := strings.TrimSpace(os.Getenv("ANIMESCHEDULE_API_TOKEN"))
|
apiToken := strings.TrimSpace(os.Getenv("ANIMESCHEDULE_API_TOKEN"))
|
||||||
|
|
||||||
if apiToken != "" {
|
if apiToken != "" {
|
||||||
return fetchWeekAPI(ctx, httpClient, apiToken, year, week)
|
return fetchWeekAPI(ctx, httpClient, apiToken, year, week, timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
location, err := scheduleLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
return WeekSchedule{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
u, _ := url.Parse("https://animeschedule.net/")
|
u, _ := url.Parse("https://animeschedule.net/")
|
||||||
@@ -147,8 +160,8 @@ func FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int)
|
|||||||
metaText := strings.Join(strings.Fields(strings.TrimSpace(meta.Text())), " ")
|
metaText := strings.Join(strings.Fields(strings.TrimSpace(meta.Text())), " ")
|
||||||
|
|
||||||
epText, _, airType := parseMeta(metaText)
|
epText, _, airType := parseMeta(metaText)
|
||||||
localTime, _, _ := parseLocalTime(meta)
|
localTime, airsAt, _, _ := parseLocalTime(meta, location)
|
||||||
if title == "" || animeURL == "" || localTime == "" || airType == "" {
|
if title == "" || animeURL == "" || localTime == "" || airType != AirTypeSUB {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +171,7 @@ func FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int)
|
|||||||
ImageURL: imageURL,
|
ImageURL: imageURL,
|
||||||
EpisodeText: epText,
|
EpisodeText: epText,
|
||||||
AirType: airType,
|
AirType: airType,
|
||||||
|
AirsAt: airsAt,
|
||||||
LocalTime: localTime,
|
LocalTime: localTime,
|
||||||
DateLabel: rawHeader,
|
DateLabel: rawHeader,
|
||||||
Weekday: *weekday,
|
Weekday: *weekday,
|
||||||
@@ -168,7 +182,7 @@ func FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
out.Days[*weekday] = append(out.Days[*weekday], dayEntries...)
|
out.Days[*weekday] = append(out.Days[*weekday], preferredReleaseEntries(dayEntries)...)
|
||||||
})
|
})
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
@@ -241,7 +255,50 @@ func parseMeta(meta string) (episodeText string, localTime string, airType AirTy
|
|||||||
return episodeText, localTime, airType
|
return episodeText, localTime, airType
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseLocalTime(meta *goquery.Selection) (localTime string, rawDatetime string, rawRenderedTime string) {
|
func preferredReleaseEntries(entries []Entry) []Entry {
|
||||||
|
type keyedEntry struct {
|
||||||
|
index int
|
||||||
|
entry Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := map[string]keyedEntry{}
|
||||||
|
for i, entry := range entries {
|
||||||
|
key := entry.AnimeURL + "\x00" + entry.EpisodeText
|
||||||
|
current, ok := selected[key]
|
||||||
|
if !ok || airTypePriority(entry.AirType) > airTypePriority(current.entry.AirType) {
|
||||||
|
selected[key] = keyedEntry{index: i, entry: entry}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]keyedEntry, 0, len(selected))
|
||||||
|
for _, entry := range selected {
|
||||||
|
out = append(out, entry)
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a keyedEntry, b keyedEntry) int {
|
||||||
|
return a.index - b.index
|
||||||
|
})
|
||||||
|
|
||||||
|
preferred := make([]Entry, 0, len(out))
|
||||||
|
for _, entry := range out {
|
||||||
|
preferred = append(preferred, entry.entry)
|
||||||
|
}
|
||||||
|
return preferred
|
||||||
|
}
|
||||||
|
|
||||||
|
func airTypePriority(airType AirType) int {
|
||||||
|
switch airType {
|
||||||
|
case AirTypeSUB:
|
||||||
|
return 3
|
||||||
|
case AirTypeDUB:
|
||||||
|
return 2
|
||||||
|
case AirTypeJPN:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLocalTime(meta *goquery.Selection, location *time.Location) (localTime string, airsAt time.Time, rawDatetime string, rawRenderedTime string) {
|
||||||
// AnimeSchedule updates rendered time client-side based on the viewer's timezone.
|
// AnimeSchedule updates rendered time client-side based on the viewer's timezone.
|
||||||
// The server-rendered HTML can show a different time string, so we prefer the `datetime`
|
// The server-rendered HTML can show a different time string, so we prefer the `datetime`
|
||||||
// attribute when available.
|
// attribute when available.
|
||||||
@@ -250,16 +307,27 @@ func parseLocalTime(meta *goquery.Selection) (localTime string, rawDatetime stri
|
|||||||
rawRenderedTime = strings.Join(strings.Fields(strings.TrimSpace(t.Text())), " ")
|
rawRenderedTime = strings.Join(strings.Fields(strings.TrimSpace(t.Text())), " ")
|
||||||
if raw, ok := t.Attr("datetime"); ok {
|
if raw, ok := t.Attr("datetime"); ok {
|
||||||
rawDatetime = raw
|
rawDatetime = raw
|
||||||
if parsed, err := time.Parse(time.RFC3339, rawDatetime); err == nil {
|
if parsed, err := parseScheduleDatetime(rawDatetime); err == nil {
|
||||||
localTime = parsed.In(scheduleLocation()).Format("03:04 PM")
|
airsAt = parsed.In(location)
|
||||||
return localTime, rawDatetime, rawRenderedTime
|
localTime = airsAt.Format("15:04")
|
||||||
|
return localTime, airsAt, rawDatetime, rawRenderedTime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fallback := strings.Join(strings.Fields(strings.TrimSpace(meta.Text())), " ")
|
fallback := strings.Join(strings.Fields(strings.TrimSpace(meta.Text())), " ")
|
||||||
_, parsedTime, _ := parseMeta(fallback)
|
_, parsedTime, _ := parseMeta(fallback)
|
||||||
return parsedTime, "", ""
|
return parsedTime, time.Time{}, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseScheduleDatetime(value string) (time.Time, error) {
|
||||||
|
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04Z07:00"} {
|
||||||
|
parsed, err := time.Parse(layout, strings.TrimSpace(value))
|
||||||
|
if err == nil {
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, fmt.Errorf("parse schedule datetime %q", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func absolutizeURL(base string, href string) string {
|
func absolutizeURL(base string, href string) string {
|
||||||
@@ -304,23 +372,24 @@ type timetableAnimeAPI struct {
|
|||||||
ImageVersionRoute string `json:"imageVersionRoute"`
|
ImageVersionRoute string `json:"imageVersionRoute"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchWeekAPI(ctx context.Context, httpClient *http.Client, token string, year int, week int) (WeekSchedule, error) {
|
func fetchWeekAPI(ctx context.Context, httpClient *http.Client, token string, year int, week int, timezone string) (WeekSchedule, error) {
|
||||||
client := httpClient
|
client := httpClient
|
||||||
if client == nil {
|
if client == nil {
|
||||||
client = http.DefaultClient
|
client = http.DefaultClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location, err := scheduleLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
return WeekSchedule{}, err
|
||||||
|
}
|
||||||
|
|
||||||
u, _ := url.Parse("https://animeschedule.net/api/v3/timetables/sub")
|
u, _ := url.Parse("https://animeschedule.net/api/v3/timetables/sub")
|
||||||
q := u.Query()
|
q := u.Query()
|
||||||
if year > 0 && week > 0 {
|
if year > 0 && week > 0 {
|
||||||
q.Set("year", strconv.Itoa(year))
|
q.Set("year", strconv.Itoa(year))
|
||||||
q.Set("week", strconv.Itoa(week))
|
q.Set("week", strconv.Itoa(week))
|
||||||
}
|
}
|
||||||
tz := strings.TrimSpace(os.Getenv("ANIMESCHEDULE_TZ"))
|
q.Set("tz", location.String())
|
||||||
if tz == "" {
|
|
||||||
tz = "Europe/Copenhagen"
|
|
||||||
}
|
|
||||||
q.Set("tz", tz)
|
|
||||||
u.RawQuery = q.Encode()
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||||
@@ -386,13 +455,13 @@ func fetchWeekAPI(ctx context.Context, httpClient *http.Client, token string, ye
|
|||||||
}
|
}
|
||||||
|
|
||||||
airType := AirType(strings.ToUpper(strings.TrimSpace(item.AirType)))
|
airType := AirType(strings.ToUpper(strings.TrimSpace(item.AirType)))
|
||||||
if airType != AirTypeJPN && airType != AirTypeSUB && airType != AirTypeDUB {
|
if airType != AirTypeSUB {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
episodeTime := item.EpisodeDate.In(time.Local)
|
episodeTime := item.EpisodeDate.In(location)
|
||||||
weekday := episodeTime.Weekday()
|
weekday := episodeTime.Weekday()
|
||||||
localTime := episodeTime.Format("03:04 PM")
|
localTime := episodeTime.Format("15:04")
|
||||||
|
|
||||||
imageURL := ""
|
imageURL := ""
|
||||||
if strings.TrimSpace(item.ImageVersionRoute) != "" {
|
if strings.TrimSpace(item.ImageVersionRoute) != "" {
|
||||||
@@ -410,6 +479,7 @@ func fetchWeekAPI(ctx context.Context, httpClient *http.Client, token string, ye
|
|||||||
ImageURL: imageURL,
|
ImageURL: imageURL,
|
||||||
EpisodeText: episodeText,
|
EpisodeText: episodeText,
|
||||||
AirType: airType,
|
AirType: airType,
|
||||||
|
AirsAt: episodeTime,
|
||||||
LocalTime: localTime,
|
LocalTime: localTime,
|
||||||
Weekday: weekday,
|
Weekday: weekday,
|
||||||
})
|
})
|
||||||
|
|||||||
101
integrations/animeschedule/animeschedule_test.go
Normal file
101
integrations/animeschedule/animeschedule_test.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package animeschedule
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseLocalTimeUsesRequestedTimezone(t *testing.T) {
|
||||||
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(`
|
||||||
|
<h3 class="time-bar">
|
||||||
|
<span class="show-episode">Ep 9</span>
|
||||||
|
<time datetime="2026-06-05T16:00+01:00" class="show-air-time">04:00 PM</time>
|
||||||
|
<span>SUB</span>
|
||||||
|
</h3>
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse document: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
location, err := time.LoadLocation("Europe/Copenhagen")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load location: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
localTime, airsAt, _, rendered := parseLocalTime(doc.Find(".time-bar").First(), location)
|
||||||
|
|
||||||
|
if localTime != "17:00" {
|
||||||
|
t.Fatalf("localTime = %q, want %q", localTime, "17:00")
|
||||||
|
}
|
||||||
|
if rendered != "04:00 PM" {
|
||||||
|
t.Fatalf("rendered = %q, want %q", rendered, "04:00 PM")
|
||||||
|
}
|
||||||
|
if airsAt.Location().String() != "Europe/Copenhagen" {
|
||||||
|
t.Fatalf("airsAt location = %q, want Europe/Copenhagen", airsAt.Location().String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLocalTimeUsesExactAngelNextDoorSubRelease(t *testing.T) {
|
||||||
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(`
|
||||||
|
<h3 class="time-bar">
|
||||||
|
<span class="show-episode">Ep 10</span>
|
||||||
|
<time datetime="2026-06-05T15:30+01:00" class="show-air-time">03:30 PM</time>
|
||||||
|
<span>SUB</span>
|
||||||
|
</h3>
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse document: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
location, err := time.LoadLocation("Europe/Copenhagen")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load location: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
localTime, _, _, _ := parseLocalTime(doc.Find(".time-bar").First(), location)
|
||||||
|
|
||||||
|
if localTime != "16:30" {
|
||||||
|
t.Fatalf("localTime = %q, want %q", localTime, "16:30")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreferredReleaseEntriesPrefersSubForSameEpisode(t *testing.T) {
|
||||||
|
entries := []Entry{
|
||||||
|
{
|
||||||
|
Title: "Tensei shitara Slime Datta Ken 4th Season",
|
||||||
|
AnimeURL: "https://animeschedule.net/anime/tensei-shitara-slime-datta-ken-4th-season",
|
||||||
|
EpisodeText: "Ep 9",
|
||||||
|
AirType: AirTypeJPN,
|
||||||
|
LocalTime: "16:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "Tensei shitara Slime Datta Ken 4th Season",
|
||||||
|
AnimeURL: "https://animeschedule.net/anime/tensei-shitara-slime-datta-ken-4th-season",
|
||||||
|
EpisodeText: "Ep 9",
|
||||||
|
AirType: AirTypeSUB,
|
||||||
|
LocalTime: "17:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "Tensei shitara Slime Datta Ken 4th Season",
|
||||||
|
AnimeURL: "https://animeschedule.net/anime/tensei-shitara-slime-datta-ken-4th-season",
|
||||||
|
EpisodeText: "Ep 6",
|
||||||
|
AirType: AirTypeDUB,
|
||||||
|
LocalTime: "17:00",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := preferredReleaseEntries(entries)
|
||||||
|
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len(got) = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0].AirType != AirTypeSUB {
|
||||||
|
t.Fatalf("first air type = %q, want %q", got[0].AirType, AirTypeSUB)
|
||||||
|
}
|
||||||
|
if got[1].AirType != AirTypeDUB {
|
||||||
|
t.Fatalf("second air type = %q, want %q", got[1].AirType, AirTypeDUB)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -357,8 +357,9 @@ func (h *AnimeHandler) HandleSchedule(c *gin.Context) {
|
|||||||
|
|
||||||
func (h *AnimeHandler) HandleScheduleSection(c *gin.Context) {
|
func (h *AnimeHandler) HandleScheduleSection(c *gin.Context) {
|
||||||
year, week := parseYearWeek(c)
|
year, week := parseYearWeek(c)
|
||||||
|
timezone := scheduleTimezone(c)
|
||||||
|
|
||||||
schedule, err := h.getCachedAnimeScheduleWeek(c.Request.Context(), year, week)
|
schedule, err := h.getCachedAnimeScheduleWeek(c.Request.Context(), year, week, timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
prevYear, prevWeek := adjacentISOWeek(year, week, -1)
|
prevYear, prevWeek := adjacentISOWeek(year, week, -1)
|
||||||
nextYear, nextWeek := adjacentISOWeek(year, week, 1)
|
nextYear, nextWeek := adjacentISOWeek(year, week, 1)
|
||||||
@@ -369,6 +370,7 @@ func (h *AnimeHandler) HandleScheduleSection(c *gin.Context) {
|
|||||||
map[string]any{
|
map[string]any{
|
||||||
"year": year,
|
"year": year,
|
||||||
"week": week,
|
"week": week,
|
||||||
|
"timezone": timezone,
|
||||||
},
|
},
|
||||||
err,
|
err,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,8 +33,16 @@ func parseYearWeek(c *gin.Context) (int, int) {
|
|||||||
return year, week
|
return year, week
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int, week int) (animeschedule.WeekSchedule, error) {
|
func scheduleTimezone(c *gin.Context) string {
|
||||||
cacheKey := fmt.Sprintf("%d-%02d", year, week)
|
timezone := strings.TrimSpace(c.Query("timezone"))
|
||||||
|
if timezone == "" {
|
||||||
|
return "UTC"
|
||||||
|
}
|
||||||
|
return timezone
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int, week int, timezone string) (animeschedule.WeekSchedule, error) {
|
||||||
|
cacheKey := fmt.Sprintf("%d-%02d-%s", year, week, timezone)
|
||||||
const ttl = 10 * time.Minute
|
const ttl = 10 * time.Minute
|
||||||
|
|
||||||
h.scheduleCacheMu.Lock()
|
h.scheduleCacheMu.Lock()
|
||||||
@@ -45,7 +53,7 @@ func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int,
|
|||||||
return cached.value, nil
|
return cached.value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
value, err := animeschedule.FetchWeek(ctx, nil, year, week)
|
value, err := animeschedule.FetchWeek(ctx, nil, year, week, timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return animeschedule.WeekSchedule{}, err
|
return animeschedule.WeekSchedule{}, err
|
||||||
}
|
}
|
||||||
@@ -71,6 +79,9 @@ func buildScheduleDays(schedule animeschedule.WeekSchedule, year int, week int)
|
|||||||
date := start.AddDate(0, 0, i)
|
date := start.AddDate(0, 0, i)
|
||||||
entries := schedule.Days[wd]
|
entries := schedule.Days[wd]
|
||||||
sort.SliceStable(entries, func(i, j int) bool {
|
sort.SliceStable(entries, func(i, j int) bool {
|
||||||
|
if !entries[i].AirsAt.IsZero() && !entries[j].AirsAt.IsZero() {
|
||||||
|
return entries[i].AirsAt.Before(entries[j].AirsAt)
|
||||||
|
}
|
||||||
return localTimeMinutes(entries[i].LocalTime) < localTimeMinutes(entries[j].LocalTime)
|
return localTimeMinutes(entries[i].LocalTime) < localTimeMinutes(entries[j].LocalTime)
|
||||||
})
|
})
|
||||||
out = append(out, scheduleDayView{
|
out = append(out, scheduleDayView{
|
||||||
@@ -83,12 +94,14 @@ func buildScheduleDays(schedule animeschedule.WeekSchedule, year int, week int)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func localTimeMinutes(localTime string) int {
|
func localTimeMinutes(localTime string) int {
|
||||||
t, err := time.Parse("03:04 PM", localTime)
|
for _, layout := range []string{"15:04", "03:04 PM"} {
|
||||||
if err != nil {
|
t, err := time.Parse(layout, localTime)
|
||||||
return 0
|
if err == nil {
|
||||||
}
|
|
||||||
return t.Hour()*60 + t.Minute()
|
return t.Hour()*60 + t.Minute()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func isoWeekStartMonday(year int, week int) time.Time {
|
func isoWeekStartMonday(year int, week int) time.Time {
|
||||||
// ISO week 1 is the week with the year's first Thursday in it.
|
// ISO week 1 is the week with the year's first Thursday in it.
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
convertJstToLocalTime,
|
|
||||||
isJstTimezone,
|
isJstTimezone,
|
||||||
normalizeWeekday,
|
normalizeWeekday,
|
||||||
parseHHMM,
|
parseHHMM,
|
||||||
@@ -73,10 +72,8 @@ interface LocalSlot {
|
|||||||
timeLabel: string;
|
timeLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatLocalTime = (hour: number, minute: number, format: TimeFormat): string => {
|
const formatLocalTime = (date: Date, format: TimeFormat): string => {
|
||||||
const use12h = format === "12";
|
const use12h = format === "12";
|
||||||
const date = new Date();
|
|
||||||
date.setHours(hour, minute, 0, 0);
|
|
||||||
const formatter = new Intl.DateTimeFormat(undefined, {
|
const formatter = new Intl.DateTimeFormat(undefined, {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
@@ -85,10 +82,33 @@ const formatLocalTime = (hour: number, minute: number, format: TimeFormat): stri
|
|||||||
return formatter.format(date);
|
return formatter.format(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dateForDayIndex = (weekStartDate: Date, weekStart: WeekStart, dayIndex: number): Date => {
|
||||||
|
const dayOrder = orderedDayIndexes(weekStart);
|
||||||
|
const startIndex = dayOrder[0] ?? 1;
|
||||||
|
const date = new Date(weekStartDate);
|
||||||
|
date.setDate(date.getDate() + ((dayIndex - startIndex + 7) % 7));
|
||||||
|
return date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const jstBroadcastInstant = (sourceDate: Date, hour: number, minute: number): Date =>
|
||||||
|
new Date(
|
||||||
|
Date.UTC(
|
||||||
|
sourceDate.getFullYear(),
|
||||||
|
sourceDate.getMonth(),
|
||||||
|
sourceDate.getDate(),
|
||||||
|
hour - 9,
|
||||||
|
minute,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const getLocalSlot = (
|
const getLocalSlot = (
|
||||||
broadcastDay: string | null,
|
broadcastDay: string | null,
|
||||||
broadcastTime: string | null,
|
broadcastTime: string | null,
|
||||||
broadcastTimezone: string | null,
|
broadcastTimezone: string | null,
|
||||||
|
weekStartDate: Date,
|
||||||
|
weekStart: WeekStart,
|
||||||
timeFormat: TimeFormat,
|
timeFormat: TimeFormat,
|
||||||
): LocalSlot | null => {
|
): LocalSlot | null => {
|
||||||
const sourceDayIndex = normalizeWeekday(broadcastDay);
|
const sourceDayIndex = normalizeWeekday(broadcastDay);
|
||||||
@@ -96,18 +116,12 @@ const getLocalSlot = (
|
|||||||
if (sourceDayIndex === null || !parsed) return null;
|
if (sourceDayIndex === null || !parsed) return null;
|
||||||
if (!isJstTimezone(broadcastTimezone)) return null;
|
if (!isJstTimezone(broadcastTimezone)) return null;
|
||||||
|
|
||||||
const localOffsetMinutes = -new Date().getTimezoneOffset();
|
const sourceDate = dateForDayIndex(weekStartDate, weekStart, sourceDayIndex);
|
||||||
|
const localDate = jstBroadcastInstant(sourceDate, parsed.hour, parsed.minute);
|
||||||
const localTime = convertJstToLocalTime(
|
const timeLabel = formatLocalTime(localDate, timeFormat);
|
||||||
sourceDayIndex,
|
|
||||||
parsed.hour,
|
|
||||||
parsed.minute,
|
|
||||||
localOffsetMinutes,
|
|
||||||
);
|
|
||||||
const timeLabel = formatLocalTime(localTime.hour, localTime.minute, timeFormat);
|
|
||||||
return {
|
return {
|
||||||
localDayIndex: localTime.dayIndex,
|
localDayIndex: localDate.getDay(),
|
||||||
localMinutes: localTime.totalMinutes,
|
localMinutes: localDate.getHours() * 60 + localDate.getMinutes(),
|
||||||
timeLabel,
|
timeLabel,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -268,6 +282,8 @@ const buildBoard = (section: HTMLElement): void => {
|
|||||||
node.getAttribute("data-broadcast-day"),
|
node.getAttribute("data-broadcast-day"),
|
||||||
node.getAttribute("data-broadcast-time"),
|
node.getAttribute("data-broadcast-time"),
|
||||||
node.getAttribute("data-broadcast-timezone"),
|
node.getAttribute("data-broadcast-timezone"),
|
||||||
|
weekStartDate,
|
||||||
|
settings.weekStart,
|
||||||
settings.timeFormat,
|
settings.timeFormat,
|
||||||
);
|
);
|
||||||
if (!slot) continue;
|
if (!slot) continue;
|
||||||
|
|||||||
@@ -7,7 +7,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div hx-get="/api/schedule?year={{.ScheduleYear}}&week={{.ScheduleWeek}}" hx-trigger="load" hx-swap="outerHTML">
|
<div
|
||||||
|
hx-get="/api/schedule?year={{.ScheduleYear}}&week={{.ScheduleWeek}}"
|
||||||
|
hx-vals='js:{timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"}'
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
{{template "schedule_skeleton"}}
|
{{template "schedule_skeleton"}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user