From 41cfb65b92b1d3423c76d9622fa04c2d41286d10 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 13 Apr 2026 16:35:22 +0800 Subject: [PATCH] Show queued subscription access and fix forecast time plotting --- frontend/components/account/AccountCenter.tsx | 44 +++++- .../dashboard/FutureForecastModal.tsx | 92 +++++++---- frontend/components/dashboard/HeaderBar.tsx | 6 +- frontend/hooks/useDashboardStore.tsx | 12 ++ frontend/lib/dashboard-types.ts | 2 + frontend/lib/dashboard-utils.ts | 148 ++++++++++++++---- src/auth/supabase_entitlement.py | 108 +++++++++++++ web/routes.py | 23 ++- 8 files changed, 374 insertions(+), 61 deletions(-) diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index 40938431..e26b6540 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -76,6 +76,9 @@ type AuthMeResponse = { subscription_plan_code?: string | null; subscription_starts_at?: string | null; subscription_expires_at?: string | null; + subscription_total_expires_at?: string | null; + subscription_queued_days?: number | null; + subscription_queued_count?: number | null; }; type PaymentPlan = { @@ -708,6 +711,7 @@ export function AccountCenter() { boundEmail: isEn ? "Bound Email" : "绑定邮箱", loginMethod: isEn ? "Sign-in Method" : "登录方式", renewalDate: isEn ? "Renewal Date" : "续费日期", + accessUntil: isEn ? "Access Until" : "可用至", authResult: isEn ? "Auth Result" : "鉴权结果", passed: isEn ? "Passed" : "通过", restricted: isEn ? "Restricted" : "受限", @@ -820,6 +824,9 @@ export function AccountCenter() { renewNow: isEn ? "Renew Now" : "立即续费", trialBadge: isEn ? "TRIAL" : "试用中", daysLeft: isEn ? "{days} days left" : "剩余 {days} 天", + queuedExtensionSummary: isEn + ? "Current plan until {current}. Queued extension: +{days} days. Total access until {total}." + : "当前订阅至 {current},已排队延长 +{days} 天,总可用至 {total}。", }), [isEn], ); @@ -1469,15 +1476,30 @@ export function AccountCenter() { const isSubscribed = Boolean(backend?.subscription_active); const planCode = String(backend?.subscription_plan_code || "").trim(); const isTrialPlan = /trial/i.test(planCode); - const expiryRaw = String( + const currentExpiryRaw = String( backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "", ).trim(); - const expiryInfo = parseSubscriptionExpiry(expiryRaw); - const expiryFormatted = formatTime(expiryRaw, locale); + const totalExpiryRaw = String( + backend?.subscription_total_expires_at || + backend?.subscription_expires_at || + user?.user_metadata?.pro_expiry || + "", + ).trim(); + const queuedExtensionDays = Math.max( + 0, + Number(backend?.subscription_queued_days || 0), + ); + const hasQueuedExtension = Boolean(isSubscribed && queuedExtensionDays > 0); + const displayExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw; + const reminderExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw || totalExpiryRaw; + const expiryInfo = parseSubscriptionExpiry(reminderExpiryRaw); + const expiryFormatted = formatTime(displayExpiryRaw, locale); + const currentExpiryFormatted = formatTime(currentExpiryRaw, locale); + const totalExpiryFormatted = formatTime(totalExpiryRaw, locale); const proExpiry = isSubscribed ? expiryFormatted !== "--" ? expiryFormatted - : expiryRaw || copy.proPendingSync + : displayExpiryRaw || copy.proPendingSync : copy.noProSubscription; const showExpiringSoon = Boolean(isSubscribed && expiryInfo && !expiryInfo.expired && expiryInfo.daysLeft <= 3); @@ -1509,6 +1531,13 @@ export function AccountCenter() { expiryInfo && (showExpiringSoon || showExpiredReminder) ? `${formatTime(expiryInfo.raw, locale)} · ${copy.daysLeft.replace("{days}", String(Math.max(expiryInfo.daysLeft, 0)))}` : ""; + const queuedExtensionSummary = hasQueuedExtension + ? copy.queuedExtensionSummary + .replace("{current}", currentExpiryFormatted) + .replace("{days}", String(queuedExtensionDays)) + .replace("{total}", totalExpiryFormatted) + : ""; + const expiryLabel = hasQueuedExtension ? copy.accessUntil : copy.renewalDate; useEffect(() => { if (!showOverlay || !canOpenCheckoutOverlay) return; @@ -2667,7 +2696,7 @@ export function AccountCenter() { /> @@ -2676,6 +2705,11 @@ export function AccountCenter() { label={copy.authResult} value={backend?.authenticated ? copy.passed : copy.restricted} /> + {queuedExtensionSummary ? ( +

+ {queuedExtensionSummary} +

+ ) : null} diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index 20cbb8ac..2e142a2f 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -40,6 +40,14 @@ function normalizeMarketValue(value?: number | null) { return Math.max(0, Math.min(1, numeric)); } +function formatMinuteAxisLabel(value: number) { + if (!Number.isFinite(value)) return ""; + const total = Math.max(0, Math.round(value)); + const hour = Math.floor(total / 60) % 24; + const minute = total % 60; + return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; +} + function WeatherIcon({ emoji, size = 32 }: { emoji: string; size?: number }) { if (emoji === "☀️") return ; if (emoji === "⛅" || emoji === "🌤️") @@ -259,9 +267,10 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { backgroundColor: "rgba(234, 179, 8, 0.05)", borderColor: "rgba(234, 179, 8, 0.8)", borderWidth: 2, - data: todayChartData.datasets.mgmHourlyPoints, + data: todayChartData.datasets.mgmHourlySeries, fill: false, label: locale === "en-US" ? "MGM Forecast" : "MGM 预测", + parsing: false, pointHoverRadius: 6, pointRadius: 3, spanGaps: true, @@ -272,9 +281,10 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { backgroundColor: "rgba(52, 211, 153, 0.05)", borderColor: "rgba(52, 211, 153, 0.6)", borderWidth: 1.5, - data: todayChartData.datasets.debPast, + data: todayChartData.datasets.debPastSeries, fill: true, label: locale === "en-US" ? "DEB Forecast" : "DEB 预测", + parsing: false, pointHoverRadius: 3, pointRadius: 0, tension: 0.3, @@ -283,9 +293,10 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { borderColor: "rgba(52, 211, 153, 0.35)", borderDash: [5, 3], borderWidth: 1.5, - data: todayChartData.datasets.debFuture, + data: todayChartData.datasets.debFutureSeries, fill: false, label: locale === "en-US" ? "DEB Forecast" : "DEB 预测", + parsing: false, pointRadius: 0, tension: 0.3, }); @@ -295,43 +306,45 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { backgroundColor: "#22d3ee", borderColor: "#22d3ee", borderWidth: 0, - data: todayChartData.datasets.metarPoints, + data: todayChartData.datasets.metarSeries, fill: false, label: todayChartData.observationLabel || (locale === "en-US" ? "Observation" : "观测实况"), order: 0, + parsing: false, pointHoverRadius: 7, pointRadius: 5, + showLine: false, }); - if ( - todayChartData.datasets.airportMetarPoints?.some((value) => value != null) - ) { + if (todayChartData.datasets.airportMetarSeries?.length > 0) { datasets.push({ backgroundColor: "#60a5fa", borderColor: "#60a5fa", borderWidth: 1, - data: todayChartData.datasets.airportMetarPoints, + data: todayChartData.datasets.airportMetarSeries, fill: false, label: locale === "en-US" ? "Airport METAR" : "机场 METAR", order: 0, + parsing: false, pointHoverRadius: 6, pointRadius: 4, showLine: false, }); } - if (todayChartData.datasets.mgmPoints.some((value) => value != null)) { + if (todayChartData.datasets.mgmSeries?.length > 0) { datasets.push({ backgroundColor: "#facc15", borderColor: "#facc15", borderWidth: 0, - data: todayChartData.datasets.mgmPoints, + data: todayChartData.datasets.mgmSeries, fill: false, label: locale === "en-US" ? "MGM Observation" : "MGM 实测", order: -1, + parsing: false, pointHoverRadius: 9, pointRadius: 7, showLine: false, @@ -346,9 +359,10 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { borderColor: "rgba(99, 102, 241, 0.2)", borderDash: [2, 4], borderWidth: 1, - data: todayChartData.datasets.temps, + data: todayChartData.datasets.tempsSeries, fill: false, label: locale === "en-US" ? "OM Raw" : "OM 原始", + parsing: false, pointRadius: 0, tension: 0.3, }); @@ -358,10 +372,11 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { backgroundColor: "#f59e0b", borderColor: "#f59e0b", borderWidth: 0, - data: todayChartData.datasets.tafCurrentMarkerPoints, + data: todayChartData.datasets.tafCurrentMarkerSeries, fill: false, label: locale === "en-US" ? "Current TAF" : "当前 TAF", order: -3, + parsing: false, pointHoverRadius: 8, pointRadius: 6, showLine: false, @@ -370,10 +385,11 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { backgroundColor: "rgba(250, 204, 21, 0.72)", borderColor: "rgba(250, 204, 21, 0.72)", borderWidth: 0, - data: todayChartData.datasets.tafPeakWindowMarkerPoints, + data: todayChartData.datasets.tafPeakWindowMarkerSeries, fill: false, label: locale === "en-US" ? "Peak-window TAF" : "峰值窗口 TAF", order: -2, + parsing: false, pointHoverRadius: 7, pointRadius: 4, showLine: false, @@ -382,10 +398,11 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { backgroundColor: "#f59e0b", borderColor: "#f59e0b", borderWidth: 0, - data: todayChartData.datasets.tafMarkerPoints, + data: todayChartData.datasets.tafMarkerSeries, fill: false, label: locale === "en-US" ? "TAF Timing" : "TAF 时段", order: -4, + parsing: false, pointHoverRadius: 0, pointRadius: 0, showLine: false, @@ -395,10 +412,10 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { return { data: { datasets, - labels: todayChartData.times, + labels: [], }, options: { - interaction: { intersect: false, mode: "index" }, + interaction: { intersect: false, mode: "nearest" }, maintainAspectRatio: false, plugins: { legend: { @@ -423,8 +440,15 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { borderColor: "rgba(34, 211, 238, 0.2)", borderWidth: 1, callbacks: { + title: (items) => { + const rawX = items?.[0]?.parsed?.x; + return rawX != null ? formatMinuteAxisLabel(Number(rawX)) : ""; + }, label: (ctx) => { const label = String(ctx.dataset.label || ""); + const raw = ctx.raw as + | { marker?: { summary?: string; markerType?: string; displayType?: string; isCurrent?: boolean; isPeakWindow?: boolean } } + | undefined; if ( label === "TAF Timing" || label === "TAF 时段" || @@ -433,10 +457,13 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { label === "Peak-window TAF" || label === "峰值窗口 TAF" ) { - const marker = (todayChartData.tafMarkers || []).find( - (item) => item.index === ctx.dataIndex, - ); + const marker = raw?.marker; if (!marker) return label; + const markerType = String(marker.markerType || ""); + const displayType = String( + marker.displayType || marker.markerType || "", + ); + const summary = String(marker.summary || ""); const prefix = marker.isCurrent && marker.isPeakWindow ? locale === "en-US" @@ -451,10 +478,9 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { ? "Peak-window TAF" : "峰值窗口 TAF" : label; - return `${prefix}: ${String(marker.summary || "").replace( - marker.markerType, - marker.displayType || marker.markerType, - )}`; + return `${prefix}: ${ + markerType ? summary.replace(markerType, displayType) : summary + }`; } const value = ctx.parsed.y; if (value == null) return label; @@ -466,12 +492,24 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { responsive: true, scales: { x: { + max: todayChartData.xMax, + min: todayChartData.xMin, grid: { color: "rgba(255,255,255,0.04)" }, + type: "linear", ticks: { - callback: (_value, index) => - typeof index === "number" && index % 3 === 0 - ? todayChartData.times[index] - : "", + callback: (value) => { + const num = Number(value); + if (!Number.isFinite(num)) return ""; + const minutes = Math.round(num); + if ( + minutes !== todayChartData.xMin && + minutes !== todayChartData.xMax && + minutes % 120 !== 0 + ) { + return ""; + } + return formatMinuteAxisLabel(minutes); + }, color: "#64748b", font: { family: "Inter", size: 10 }, maxRotation: 0, diff --git a/frontend/components/dashboard/HeaderBar.tsx b/frontend/components/dashboard/HeaderBar.tsx index 1c531f5b..6c9a9d27 100644 --- a/frontend/components/dashboard/HeaderBar.tsx +++ b/frontend/components/dashboard/HeaderBar.tsx @@ -38,7 +38,11 @@ export function HeaderBar() { const accountAria = isAuthenticated ? t("header.accountAria") : t("header.signInAria"); - const expiryInfo = parseExpiryInfo(store.proAccess.subscriptionExpiresAt); + const effectiveExpiry = store.proAccess.subscriptionActive + ? store.proAccess.subscriptionTotalExpiresAt || + store.proAccess.subscriptionExpiresAt + : store.proAccess.subscriptionExpiresAt; + const expiryInfo = parseExpiryInfo(effectiveExpiry); const isTrialPlan = /trial/i.test( String(store.proAccess.subscriptionPlanCode || ""), ); diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index 0fca09cf..ba81133d 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -81,6 +81,8 @@ function getInitialProAccessState(): ProAccessState { subscriptionActive: false, subscriptionPlanCode: null, subscriptionExpiresAt: null, + subscriptionTotalExpiresAt: null, + subscriptionQueuedDays: 0, points: 0, error: null, }; @@ -593,6 +595,8 @@ export function DashboardStoreProvider({ subscription_active?: boolean | null; subscription_plan_code?: string | null; subscription_expires_at?: string | null; + subscription_total_expires_at?: string | null; + subscription_queued_days?: number | null; points?: number; }; setProAccess({ @@ -602,6 +606,12 @@ export function DashboardStoreProvider({ subscriptionActive: payload.subscription_active === true, subscriptionPlanCode: payload.subscription_plan_code ?? null, subscriptionExpiresAt: payload.subscription_expires_at ?? null, + subscriptionTotalExpiresAt: + payload.subscription_total_expires_at ?? payload.subscription_expires_at ?? null, + subscriptionQueuedDays: Math.max( + 0, + Number(payload.subscription_queued_days ?? 0), + ), points: payload.points ?? 0, error: null, }); @@ -613,6 +623,8 @@ export function DashboardStoreProvider({ subscriptionActive: false, subscriptionPlanCode: null, subscriptionExpiresAt: null, + subscriptionTotalExpiresAt: null, + subscriptionQueuedDays: 0, points: 0, error: String(error), }); diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 6399b442..f65e3aac 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -516,6 +516,8 @@ export interface ProAccessState { subscriptionActive: boolean; subscriptionPlanCode: string | null; subscriptionExpiresAt: string | null; + subscriptionTotalExpiresAt: string | null; + subscriptionQueuedDays: number; points: number; error: string | null; } diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index 716e63ea..2b9f66b4 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -248,6 +248,70 @@ function hmToMinutes(value?: string | null) { return hour * 60 + minute; } +function findNearestTimeIndex( + times: string[], + targetTime?: string | null, +) { + const targetMinutes = hmToMinutes(targetTime); + if (targetMinutes == null || !times.length) return -1; + let nearestIndex = -1; + let nearestDelta = Number.POSITIVE_INFINITY; + times.forEach((time, index) => { + const minute = hmToMinutes(time); + if (minute == null) return; + const delta = Math.abs(minute - targetMinutes); + if (delta < nearestDelta) { + nearestDelta = delta; + nearestIndex = index; + } + }); + return nearestIndex; +} + +function buildTemperatureTickLabels(times: string[]) { + const lastIndex = Math.max(0, times.length - 1); + return times.map((time, index) => { + if (index === 0 || index === lastIndex) return time; + const minute = hmToMinutes(time); + if (minute == null) return ""; + const hour = Math.floor(minute / 60); + const minutePart = minute % 60; + if (minutePart !== 0) return ""; + return hour % 2 === 0 ? time : ""; + }); +} + +function buildSeriesPoints( + times: string[], + values: Array, +) { + return times + .map((time, index) => { + const x = hmToMinutes(time); + const y = values[index]; + return x != null && y != null && Number.isFinite(Number(y)) + ? { index, labelTime: time, x, y: Number(y) } + : null; + }) + .filter( + (point): point is { index: number; labelTime: string; x: number; y: number } => + point != null, + ); +} + +function buildObservationPoints(items: Array<{ time?: string; temp?: number | null }>) { + return items + .map((item) => { + const labelTime = normalizeHm(String(item.time || "")); + const x = hmToMinutes(labelTime); + const y = item.temp; + return x != null && y != null && Number.isFinite(Number(y)) + ? { labelTime: labelTime || "", x, y: Number(y) } + : null; + }) + .filter((point): point is { labelTime: string; x: number; y: number } => point != null); +} + function interpolateSeriesAtMinutes( times: string[], values: Array, @@ -516,10 +580,7 @@ export function getTemperatureChartData( if (!times.length) return null; - const currentHour = detail.local_time - ? `${detail.local_time.split(":")[0]}:00` - : null; - const currentIndex = currentHour ? times.indexOf(currentHour) : -1; + const currentIndex = findNearestTimeIndex(times, detail.local_time); const omMax = detail.forecast?.today_high; const debMax = detail.deb?.prediction; const offset = @@ -582,11 +643,7 @@ export function getTemperatureChartData( const metarPoints = new Array(times.length).fill(null); observationSource.forEach((item) => { - const parts = String(item.time || "").split(":"); - let hour = Number.parseInt(parts[0], 10); - if (Number.isNaN(hour)) return; - const key = `${String(hour).padStart(2, "0")}:00`; - const index = times.indexOf(key); + const index = findNearestTimeIndex(times, String(item.time || "")); const temp = item.temp ?? null; if (index >= 0 && temp != null) { const existing = metarPoints[index]; @@ -598,11 +655,7 @@ export function getTemperatureChartData( }); const airportMetarPoints = new Array(times.length).fill(null); airportMetarSource.forEach((item) => { - const parts = String(item.time || "").split(":"); - const hour = Number.parseInt(parts[0], 10); - if (Number.isNaN(hour)) return; - const key = `${String(hour).padStart(2, "0")}:00`; - const index = times.indexOf(key); + const index = findNearestTimeIndex(times, String(item.time || "")); const temp = item.temp ?? null; if (index >= 0 && temp != null) { const existing = airportMetarPoints[index]; @@ -617,24 +670,16 @@ export function getTemperatureChartData( detail.mgm?.temp != null && detail.mgm?.time ) { - const match = detail.mgm.time.match(/T?(\d{2}):(\d{2})/); - if (match) { - let hour = Number.parseInt(match[1], 10); - const key = `${String(hour).padStart(2, "0")}:00`; - const index = times.indexOf(key); - if (index >= 0) { - mgmPoints[index] = detail.mgm.temp; - } + const index = findNearestTimeIndex(times, detail.mgm.time); + if (index >= 0) { + mgmPoints[index] = detail.mgm.temp; } } const mgmHourlyPoints = new Array(times.length).fill(null); let hasMgmHourly = false; detail.mgm?.hourly?.forEach((item) => { - const match = String(item.time || "").match(/T?(\d{2}):(\d{2})/); - if (!match) return; - const key = `${match[1]}:00`; - const index = times.indexOf(key); + const index = findNearestTimeIndex(times, String(item.time || "")); if (index >= 0) { mgmHourlyPoints[index] = item.temp ?? null; hasMgmHourly = true; @@ -723,7 +768,7 @@ export function getTemperatureChartData( const tafMarkers = tafMarkersRaw .map((marker) => { const labelTime = String(marker?.label_time || "").trim(); - const index = times.indexOf(labelTime); + const index = findNearestTimeIndex(times, labelTime); if (index >= 0) { tafMarkerPoints[index] = tafMarkerValue; } @@ -890,20 +935,66 @@ export function getTemperatureChartData( ); } + const debPastSeries = buildSeriesPoints(times, debPast); + const debFutureSeries = buildSeriesPoints(times, debFuture); + const tempsSeries = buildSeriesPoints(times, temps); + const mgmHourlySeries = buildSeriesPoints(times, mgmHourlyPoints); + const metarSeries = buildObservationPoints(observationSource); + const airportMetarSeries = buildObservationPoints(airportMetarSource); + const mgmSeries = + !suppressAnkaraMgmObservation && detail.mgm?.temp != null && detail.mgm?.time + ? buildObservationPoints([{ time: detail.mgm.time, temp: detail.mgm.temp }]) + : []; + const tafCurrentMarkerSeries = tafMarkers + .filter((marker) => marker.isCurrent) + .map((marker) => ({ + marker, + x: hmToMinutes(marker.labelTime) ?? 0, + y: tafMarkerValue, + })) + .filter((point) => point.x > 0); + const tafPeakWindowMarkerSeries = tafMarkers + .filter((marker) => marker.isPeakWindow && !marker.isCurrent) + .map((marker) => ({ + marker, + x: hmToMinutes(marker.labelTime) ?? 0, + y: tafMarkerValue - 0.15, + })) + .filter((point) => point.x > 0); + const tafMarkerSeries = tafMarkers + .map((marker) => ({ + marker, + x: hmToMinutes(marker.labelTime) ?? 0, + y: tafMarkerValue, + })) + .filter((point) => point.x > 0); + const xMin = times.length ? hmToMinutes(times[0]) ?? 0 : 0; + const xMax = times.length ? hmToMinutes(times[times.length - 1]) ?? 24 * 60 : 24 * 60; + return { datasets: { airportMetarPoints, + airportMetarSeries, debFuture, + debFutureSeries, debPast, + debPastSeries, hasMgmHourly, metarPoints, + metarSeries, mgmHourlyPoints, + mgmHourlySeries, mgmPoints, + mgmSeries, offset, tafCurrentMarkerPoints, + tafCurrentMarkerSeries, tafMarkerPoints, + tafMarkerSeries, tafPeakWindowMarkerPoints, + tafPeakWindowMarkerSeries, temps, + tempsSeries, }, observationLabel: observationCode === "noaa" && @@ -918,7 +1009,10 @@ export function getTemperatureChartData( max, min, tafMarkers, + tickLabels: buildTemperatureTickLabels(times), times, + xMax, + xMin, }; } diff --git a/src/auth/supabase_entitlement.py b/src/auth/supabase_entitlement.py index e7db2be5..ce516c06 100644 --- a/src/auth/supabase_entitlement.py +++ b/src/auth/supabase_entitlement.py @@ -233,6 +233,69 @@ class SupabaseEntitlementService: logger.warning(f"supabase subscription query error user_id={user_id}: {exc}") return None + def _query_active_subscription_rows( + self, + user_id: str, + ) -> List[Dict[str, object]]: + if not user_id: + return [] + if not self.service_role_key: + logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing") + return [] + + now_ts = time.time() + with self._sub_cache_lock: + cached = self._sub_cache.get(user_id) + if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec: + rows = cached.get("rows") + if isinstance(rows, list): + return [row for row in rows if isinstance(row, dict)] + row = cached.get("row") + if isinstance(row, dict): + return [row] + return [] + + try: + now = datetime.now(timezone.utc) + now_iso = now.isoformat() + params = { + "select": "id,user_id,status,plan_code,starts_at,expires_at,source,created_at,updated_at", + "user_id": f"eq.{user_id}", + "status": "eq.active", + "expires_at": f"gt.{now_iso}", + "order": "expires_at.desc", + "limit": "100", + } + response = requests.get( + self._subscription_endpoint(), + headers=self._request_headers_for_service_role(), + params=params, + timeout=self.timeout_sec, + ) + if response.status_code != 200: + logger.warning( + "supabase active subscription rows query failed user_id={} status={}", + user_id, + response.status_code, + ) + rows: List[Dict[str, object]] = [] + else: + data = response.json() if response.content else [] + rows = [row for row in data if isinstance(row, dict)] if isinstance(data, list) else [] + + current_row = self._pick_latest_current_subscription(rows, now=now) + with self._sub_cache_lock: + self._sub_cache[user_id] = { + "active": bool(current_row), + "row": current_row, + "rows": rows, + "ts": now_ts, + } + return rows + except Exception as exc: + logger.warning(f"supabase active subscription rows query error user_id={user_id}: {exc}") + return [] + def _query_latest_subscription_any_status( self, user_id: str, @@ -453,6 +516,51 @@ class SupabaseEntitlementService: ) -> Optional[Dict[str, object]]: return self._query_latest_subscription_any_status(user_id) + def get_subscription_window( + self, + user_id: str, + respect_requirement: bool = True, + ) -> Dict[str, object]: + if respect_requirement and not self.require_subscription: + return {} + rows = self._query_active_subscription_rows(user_id) + if not rows: + return {} + + now = datetime.now(timezone.utc) + current = self._pick_latest_current_subscription(rows, now=now) + total_expiry: Optional[datetime] = None + current_expiry: Optional[datetime] = None + if isinstance(current, dict): + current_expiry = self._parse_iso_datetime(str(current.get("expires_at") or "")) + + queued_count = 0 + for row in rows: + exp = self._parse_iso_datetime(str(row.get("expires_at") or "")) + if exp is not None and (total_expiry is None or exp > total_expiry): + total_expiry = exp + if current_expiry is not None: + starts = self._parse_iso_datetime(str(row.get("starts_at") or "")) + if starts is not None and starts >= current_expiry and row is not current: + queued_count += 1 + + queued_days = 0 + if total_expiry is not None and current_expiry is not None and total_expiry > current_expiry: + queued_days = max( + 0, + int(round((total_expiry - current_expiry).total_seconds() / 86_400)), + ) + + return { + "current": current, + "current_expires_at": current.get("expires_at") if isinstance(current, dict) else None, + "current_starts_at": current.get("starts_at") if isinstance(current, dict) else None, + "total_expires_at": total_expiry.isoformat() if total_expiry else None, + "queued_days": queued_days, + "queued_count": queued_count, + "rows": rows, + } + def has_active_subscription( self, user_id: str, diff --git a/web/routes.py b/web/routes.py index abad8571..934f7c37 100644 --- a/web/routes.py +++ b/web/routes.py @@ -561,6 +561,9 @@ async def auth_me(request: Request): subscription_plan_code = None subscription_starts_at = None subscription_expires_at = None + subscription_total_expires_at = None + subscription_queued_days = 0 + subscription_queued_count = 0 if SUPABASE_ENTITLEMENT.enabled and user_id: try: @@ -593,16 +596,31 @@ async def auth_me(request: Request): latest_known_subscription = ( SUPABASE_ENTITLEMENT.get_latest_subscription_any_status(user_id) ) + subscription_window = SUPABASE_ENTITLEMENT.get_subscription_window( + user_id, + respect_requirement=False, + ) subscription_active = bool(latest_subscription) - if isinstance(latest_known_subscription, dict): + if isinstance(latest_subscription, dict): + subscription_plan_code = latest_subscription.get("plan_code") + subscription_starts_at = latest_subscription.get("starts_at") + subscription_expires_at = latest_subscription.get("expires_at") + elif isinstance(latest_known_subscription, dict): subscription_plan_code = latest_known_subscription.get("plan_code") subscription_starts_at = latest_known_subscription.get("starts_at") subscription_expires_at = latest_known_subscription.get("expires_at") + if isinstance(subscription_window, dict): + subscription_total_expires_at = subscription_window.get("total_expires_at") + subscription_queued_days = int(subscription_window.get("queued_days") or 0) + subscription_queued_count = int(subscription_window.get("queued_count") or 0) except Exception: subscription_active = None subscription_plan_code = None subscription_starts_at = None subscription_expires_at = None + subscription_total_expires_at = None + subscription_queued_days = 0 + subscription_queued_count = 0 points = _resolve_auth_points(request) weekly_profile = _resolve_weekly_profile(request) @@ -629,6 +647,9 @@ async def auth_me(request: Request): "subscription_plan_code": subscription_plan_code, "subscription_starts_at": subscription_starts_at, "subscription_expires_at": subscription_expires_at, + "subscription_total_expires_at": subscription_total_expires_at, + "subscription_queued_days": subscription_queued_days, + "subscription_queued_count": subscription_queued_count, }