diff --git a/frontend/src/i18n/locales/en/admin/channels.ts b/frontend/src/i18n/locales/en/admin/channels.ts index 96c0a6cbdf..7907b1ee98 100644 --- a/frontend/src/i18n/locales/en/admin/channels.ts +++ b/frontend/src/i18n/locales/en/admin/channels.ts @@ -623,7 +623,9 @@ export default { quotaEndsInMinutes: 'Quota ends in {minutes}m', quotaEndsInHoursMinutes: 'Quota ends in {hours}h {minutes}m', quotaEndsInDaysHours: 'Quota ends in {days}d {hours}h', - daysRemaining: 'days remaining', + daysRemaining: '{days} days remaining', + hoursMinutesRemaining: '{hours}h {minutes}m remaining', + minutesRemaining: '{minutes}m remaining', remainingDays: 'Remaining days', noExpiration: 'No expiration', status: { diff --git a/frontend/src/i18n/locales/zh/admin/channels.ts b/frontend/src/i18n/locales/zh/admin/channels.ts index cdfb185c8a..b95be9352b 100644 --- a/frontend/src/i18n/locales/zh/admin/channels.ts +++ b/frontend/src/i18n/locales/zh/admin/channels.ts @@ -623,7 +623,9 @@ export default { quotaEndsInMinutes: '额度将在 {minutes} 分钟后结束', quotaEndsInHoursMinutes: '额度将在 {hours} 小时 {minutes} 分钟后结束', quotaEndsInDaysHours: '额度将在 {days} 天 {hours} 小时后结束', - daysRemaining: '天剩余', + daysRemaining: '剩余 {days} 天', + hoursMinutesRemaining: '剩余 {hours} 小时 {minutes} 分钟', + minutesRemaining: '剩余 {minutes} 分钟', remainingDays: '剩余天数', noExpiration: '无过期时间', status: { diff --git a/frontend/src/utils/__tests__/subscriptionQuota.spec.ts b/frontend/src/utils/__tests__/subscriptionQuota.spec.ts new file mode 100644 index 0000000000..9cc8bc7071 --- /dev/null +++ b/frontend/src/utils/__tests__/subscriptionQuota.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' + +import { getExpirationDateRelation, getRemainingExpiryDuration } from '../subscriptionQuota' + +describe('subscription expiry timing', () => { + it('uses local calendar dates for today and tomorrow', () => { + const now = new Date(2026, 2, 7, 23, 30) + + expect(getExpirationDateRelation(new Date(2026, 2, 7, 23, 45), now)).toBe('today') + expect(getExpirationDateRelation(new Date(2026, 2, 8, 3, 30), now)).toBe('tomorrow') + }) + + it('treats the exact expiry instant and elapsed expiries as expired', () => { + const now = new Date(2026, 6, 30, 9, 0) + + expect(getExpirationDateRelation(now, now)).toBe('expired') + expect(getRemainingExpiryDuration(now, now)).toBeNull() + expect(getExpirationDateRelation(new Date(2026, 6, 30, 8, 59), now)).toBe('expired') + expect(getRemainingExpiryDuration(new Date(2026, 6, 30, 8, 59), now)).toBeNull() + }) + + it('rejects invalid target and current dates', () => { + const invalid = new Date('invalid') + const valid = new Date(2026, 6, 30, 9, 0) + + expect(getExpirationDateRelation(invalid, valid)).toBeNull() + expect(getExpirationDateRelation(valid, invalid)).toBeNull() + expect(getRemainingExpiryDuration(invalid, valid)).toBeNull() + expect(getRemainingExpiryDuration(valid, invalid)).toBeNull() + }) + + it('returns rounded-up hours and minutes for an expiry under 24 hours away', () => { + const now = new Date(2026, 6, 30, 9, 0) + + expect(getRemainingExpiryDuration(new Date(2026, 6, 31, 8, 30), now)).toEqual({ + unit: 'hoursMinutes', + hours: 23, + minutes: 30 + }) + expect(getRemainingExpiryDuration(new Date(now.getTime() + 1), now)).toEqual({ + unit: 'hoursMinutes', + hours: 0, + minutes: 1 + }) + expect(getRemainingExpiryDuration(new Date(now.getTime() + 23 * 60 * 60 * 1000 + 1), now)).toEqual({ + unit: 'hoursMinutes', + hours: 23, + minutes: 1 + }) + }) + + it('preserves rounded-up day display from 24 hours onward', () => { + const now = new Date(2026, 6, 30, 9, 0) + + expect(getRemainingExpiryDuration(new Date(now.getTime() + 24 * 60 * 60 * 1000), now)).toEqual({ + unit: 'days', + days: 1 + }) + expect(getRemainingExpiryDuration(new Date(now.getTime() + 24 * 60 * 60 * 1000 + 1), now)).toEqual({ + unit: 'days', + days: 2 + }) + }) +}) diff --git a/frontend/src/utils/subscriptionQuota.ts b/frontend/src/utils/subscriptionQuota.ts index 719737bebc..d4135aa4bc 100644 --- a/frontend/src/utils/subscriptionQuota.ts +++ b/frontend/src/utils/subscriptionQuota.ts @@ -2,6 +2,12 @@ import type { UserSubscription } from '@/types' const ONE_DAY_MS = 24 * 60 * 60 * 1000 +export type ExpirationDateRelation = 'expired' | 'today' | 'tomorrow' | 'later' + +export type RemainingExpiryDuration = + | { unit: 'days'; days: number } + | { unit: 'hoursMinutes'; hours: number; minutes: number } + export interface RemainingDurationParts { days: number hours: number @@ -40,3 +46,46 @@ export function getRemainingDurationParts( return { days, hours, minutes } } + +export function getExpirationDateRelation( + targetAt: Date | string, + now: Date = new Date() +): ExpirationDateRelation | null { + const target = targetAt instanceof Date ? targetAt : new Date(targetAt) + const targetTime = target.getTime() + const nowTime = now.getTime() + + if (!Number.isFinite(targetTime) || !Number.isFinite(nowTime)) return null + if (targetTime <= nowTime) return 'expired' + + const targetDay = Date.UTC(target.getFullYear(), target.getMonth(), target.getDate()) + const currentDay = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) + const calendarDays = Math.round((targetDay - currentDay) / ONE_DAY_MS) + + if (calendarDays === 0) return 'today' + if (calendarDays === 1) return 'tomorrow' + return 'later' +} + +export function getRemainingExpiryDuration( + targetAt: Date | string, + now: Date = new Date() +): RemainingExpiryDuration | null { + const targetTime = targetAt instanceof Date ? targetAt.getTime() : new Date(targetAt).getTime() + const nowTime = now.getTime() + + if (!Number.isFinite(targetTime) || !Number.isFinite(nowTime)) return null + + const diffMs = targetTime - nowTime + if (diffMs <= 0) return null + if (diffMs >= ONE_DAY_MS) { + return { unit: 'days', days: Math.ceil(diffMs / ONE_DAY_MS) } + } + + const totalMinutes = Math.ceil(diffMs / (60 * 1000)) + return { + unit: 'hoursMinutes', + hours: Math.floor(totalMinutes / 60), + minutes: totalMinutes % 60 + } +} diff --git a/frontend/src/views/admin/SubscriptionsView.vue b/frontend/src/views/admin/SubscriptionsView.vue index ff44e52a21..b23fa2fdc7 100644 --- a/frontend/src/views/admin/SubscriptionsView.vue +++ b/frontend/src/views/admin/SubscriptionsView.vue @@ -353,9 +353,14 @@ > {{ formatDateTimeToMinute(value) }} -