Show queued subscription access and fix forecast time plotting

This commit is contained in:
2569718930@qq.com
2026-04-13 16:35:22 +08:00
parent 57acbc41c9
commit 41cfb65b92
8 changed files with 374 additions and 61 deletions
+39 -5
View File
@@ -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() {
/>
<InfoRow
icon={Clock}
label={copy.renewalDate}
label={expiryLabel}
value={proExpiry}
isPrimary
/>
@@ -2676,6 +2705,11 @@ export function AccountCenter() {
label={copy.authResult}
value={backend?.authenticated ? copy.passed : copy.restricted}
/>
{queuedExtensionSummary ? (
<p className="rounded-2xl border border-cyan-400/20 bg-cyan-500/10 px-4 py-3 text-xs text-cyan-100">
{queuedExtensionSummary}
</p>
) : null}
</section>
</div>
@@ -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 <Sun size={size} color="#facc15" />;
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,
+5 -1
View File
@@ -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 || ""),
);
+12
View File
@@ -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),
});
+2
View File
@@ -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;
}
+121 -27
View File
@@ -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<number | null | undefined>,
) {
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<number | null | undefined>,
@@ -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,
};
}
+108
View File
@@ -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,
+22 -1
View File
@@ -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,
}