diff --git a/internal/templates/notifications.templ b/internal/templates/notifications.templ
index c4f9837..6f0de80 100644
--- a/internal/templates/notifications.templ
+++ b/internal/templates/notifications.templ
@@ -4,6 +4,7 @@ import "mal/internal/jikan"
import "mal/internal/database"
import "mal/internal/shared/ui"
import "fmt"
+import "strings"
type WatchingAnimeWithDetails struct {
Entry database.GetWatchingAnimeRow
@@ -102,6 +103,14 @@ templ UpcomingSeasonCard(item database.GetUpcomingSeasonsRow) {
{ displaySeasonTitle(item) }
+
+ if item.Status.Valid {
+ { seasonStatusLabel(item.Status.String) }
+ }
+ if strings.TrimSpace(item.PrequelTitle) != "" {
+ { fmt.Sprintf("Sequel to %s", item.PrequelTitle) }
+ }
+
}
}
@@ -124,7 +133,8 @@ templ NotificationCard(item WatchingAnimeWithDetails) {
if item.Anime.Broadcast.String != "" {
- { item.Anime.Broadcast.String }
+ { item.Anime.Broadcast.String }
+ Calculating next episode time...
}
if item.Anime.Episodes > 0 {
@@ -147,3 +157,20 @@ templ NotificationCard(item WatchingAnimeWithDetails) {
func displayTitle(entry database.GetWatchingAnimeRow) string {
return database.DisplayTitle(entry.TitleEnglish, entry.TitleJapanese, entry.TitleOriginal)
}
+
+func seasonStatusLabel(status string) string {
+ statusText := strings.TrimSpace(status)
+ if statusText == "" {
+ return ""
+ }
+
+ if statusText == "Currently Airing" {
+ return "Airing now"
+ }
+
+ if statusText == "Not yet aired" {
+ return "Upcoming"
+ }
+
+ return statusText
+}
diff --git a/static/js/timezone.js b/static/js/timezone.js
index 8e7d01d..66ce69b 100644
--- a/static/js/timezone.js
+++ b/static/js/timezone.js
@@ -1,7 +1,56 @@
;(function () {
const jstOffsetMinutes = 9 * 60
+ const parseBroadcastTime = (value) => {
+ if (!value || typeof value !== 'string') {
+ return null
+ }
+
+ const match = value.trim().match(/^(\d{1,2}):(\d{2})$/)
+ if (!match) {
+ return null
+ }
+
+ const hour = Number.parseInt(match[1], 10)
+ const minute = Number.parseInt(match[2], 10)
+ if (Number.isNaN(hour) || Number.isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
+ return null
+ }
+
+ return { hour, minute }
+ }
+
+ const isJstTimezone = (timezone) => {
+ if (!timezone) {
+ return true
+ }
+
+ const normalized = timezone.trim().toLowerCase()
+ return normalized === 'asia/tokyo' || normalized === 'jst'
+ }
+
+ const parseFromStructuredAttrs = (node) => {
+ const day = node.getAttribute('data-broadcast-day')
+ const time = node.getAttribute('data-broadcast-time')
+ const timezone = node.getAttribute('data-broadcast-timezone')
+
+ if (!day || !time || !isJstTimezone(timezone)) {
+ return null
+ }
+
+ const parsedTime = parseBroadcastTime(time)
+ if (!parsedTime) {
+ return null
+ }
+
+ return { day: day.trim(), hour: parsedTime.hour, minute: parsedTime.minute }
+ }
+
const parseBroadcast = (text) => {
+ if (!text || typeof text !== 'string') {
+ return null
+ }
+
const match = text.match(/^(.*) at (\d{1,2}):(\d{2}) \(JST\)$/i)
if (!match) {
return null
@@ -15,6 +64,10 @@
return null
}
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
+ return null
+ }
+
return { day, hour, minute }
}
@@ -69,43 +122,117 @@
return `${localDay} at ${time} (Local)`
}
- const updateNode = (node, localOffsetMinutes) => {
- const source = node.getAttribute('data-jst-text')
- if (!source) {
+ const nextAiringUTC = (parsed) => {
+ const targetDay = normalizeDay(parsed.day)
+ if (targetDay === null) {
+ return null
+ }
+
+ const now = new Date()
+ const jstNow = new Date(now.getTime() + jstOffsetMinutes * 60 * 1000)
+
+ const currentDay = jstNow.getUTCDay()
+ const currentMinuteOfDay = jstNow.getUTCHours() * 60 + jstNow.getUTCMinutes()
+ const targetMinuteOfDay = parsed.hour * 60 + parsed.minute
+
+ let dayDelta = (targetDay - currentDay + 7) % 7
+ if (dayDelta === 0 && targetMinuteOfDay <= currentMinuteOfDay) {
+ dayDelta = 7
+ }
+
+ const minuteDelta = dayDelta * 1440 + (targetMinuteOfDay - currentMinuteOfDay)
+ return new Date(now.getTime() + minuteDelta * 60 * 1000)
+ }
+
+ const relativeText = (target) => {
+ const diffMs = target.getTime() - Date.now()
+ if (diffMs <= 0) {
+ return 'soon'
+ }
+
+ const minutes = Math.ceil(diffMs / 60000)
+ if (minutes < 60) {
+ return formatRelative(minutes, 'minute')
+ }
+
+ const hours = Math.ceil(minutes / 60)
+ if (hours < 36) {
+ return formatRelative(hours, 'hour')
+ }
+
+ const days = Math.ceil(hours / 24)
+ return formatRelative(days, 'day')
+ }
+
+ const formatRelative = (value, unit) => {
+ if (typeof Intl !== 'undefined' && typeof Intl.RelativeTimeFormat === 'function') {
+ const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
+ return formatter.format(value, unit)
+ }
+
+ const suffix = value === 1 ? unit : `${unit}s`
+ return `in ${value} ${suffix}`
+ }
+
+ const localDateTimeText = (date) => {
+ const formatter = new Intl.DateTimeFormat(undefined, {
+ weekday: 'short',
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+ return formatter.format(date)
+ }
+
+ const updateNextAiring = (node, parsed) => {
+ const card = node.closest('.notification-content')
+ if (!card) {
return
}
- const parsed = parseBroadcast(source)
+ const nextNode = card.querySelector('[data-next-airing]')
+ if (!(nextNode instanceof HTMLElement)) {
+ return
+ }
+
+ const nextDate = nextAiringUTC(parsed)
+ if (!nextDate) {
+ nextNode.remove()
+ return
+ }
+
+ nextNode.textContent = `Next episode ${relativeText(nextDate)} (${localDateTimeText(nextDate)})`
+ }
+
+ const updateNode = (node, localOffsetMinutes) => {
+ const card = node.closest('.notification-content')
+ const nextNode = card ? card.querySelector('[data-next-airing]') : null
+
+ const structured = parseFromStructuredAttrs(node)
+ const source = node.getAttribute('data-jst-text')
+ const parsed = structured || parseBroadcast(source)
if (!parsed) {
+ if (nextNode instanceof HTMLElement) {
+ nextNode.remove()
+ }
return
}
const converted = convertToLocal(parsed, localOffsetMinutes)
if (!converted) {
+ if (nextNode instanceof HTMLElement) {
+ nextNode.remove()
+ }
return
}
node.textContent = converted
- }
-
- const hideJstSuffix = () => {
- document.querySelectorAll('.notification-broadcast, [data-jst-text]').forEach((node) => {
- if (!(node instanceof HTMLElement)) {
- return
- }
-
- const text = node.textContent || ''
- if (text.includes('(JST)')) {
- node.textContent = text.replace(/\s*\(JST\)/gi, ' (Local)')
- }
- })
+ updateNextAiring(node, parsed)
}
const updateAll = () => {
const localOffsetMinutes = -new Date().getTimezoneOffset()
const nodes = document.querySelectorAll('[data-jst-text]')
nodes.forEach((node) => updateNode(node, localOffsetMinutes))
- hideJstSuffix()
}
document.addEventListener('DOMContentLoaded', updateAll)