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,
}