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)