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_plan_code?: string | null;
subscription_starts_at?: string | null; subscription_starts_at?: string | null;
subscription_expires_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 = { type PaymentPlan = {
@@ -708,6 +711,7 @@ export function AccountCenter() {
boundEmail: isEn ? "Bound Email" : "绑定邮箱", boundEmail: isEn ? "Bound Email" : "绑定邮箱",
loginMethod: isEn ? "Sign-in Method" : "登录方式", loginMethod: isEn ? "Sign-in Method" : "登录方式",
renewalDate: isEn ? "Renewal Date" : "续费日期", renewalDate: isEn ? "Renewal Date" : "续费日期",
accessUntil: isEn ? "Access Until" : "可用至",
authResult: isEn ? "Auth Result" : "鉴权结果", authResult: isEn ? "Auth Result" : "鉴权结果",
passed: isEn ? "Passed" : "通过", passed: isEn ? "Passed" : "通过",
restricted: isEn ? "Restricted" : "受限", restricted: isEn ? "Restricted" : "受限",
@@ -820,6 +824,9 @@ export function AccountCenter() {
renewNow: isEn ? "Renew Now" : "立即续费", renewNow: isEn ? "Renew Now" : "立即续费",
trialBadge: isEn ? "TRIAL" : "试用中", trialBadge: isEn ? "TRIAL" : "试用中",
daysLeft: isEn ? "{days} days left" : "剩余 {days} 天", daysLeft: isEn ? "{days} days left" : "剩余 {days} 天",
queuedExtensionSummary: isEn
? "Current plan until {current}. Queued extension: +{days} days. Total access until {total}."
: "当前订阅至 {current},已排队延长 +{days} 天,总可用至 {total}。",
}), }),
[isEn], [isEn],
); );
@@ -1469,15 +1476,30 @@ export function AccountCenter() {
const isSubscribed = Boolean(backend?.subscription_active); const isSubscribed = Boolean(backend?.subscription_active);
const planCode = String(backend?.subscription_plan_code || "").trim(); const planCode = String(backend?.subscription_plan_code || "").trim();
const isTrialPlan = /trial/i.test(planCode); const isTrialPlan = /trial/i.test(planCode);
const expiryRaw = String( const currentExpiryRaw = String(
backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "", backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "",
).trim(); ).trim();
const expiryInfo = parseSubscriptionExpiry(expiryRaw); const totalExpiryRaw = String(
const expiryFormatted = formatTime(expiryRaw, locale); 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 const proExpiry = isSubscribed
? expiryFormatted !== "--" ? expiryFormatted !== "--"
? expiryFormatted ? expiryFormatted
: expiryRaw || copy.proPendingSync : displayExpiryRaw || copy.proPendingSync
: copy.noProSubscription; : copy.noProSubscription;
const showExpiringSoon = const showExpiringSoon =
Boolean(isSubscribed && expiryInfo && !expiryInfo.expired && expiryInfo.daysLeft <= 3); Boolean(isSubscribed && expiryInfo && !expiryInfo.expired && expiryInfo.daysLeft <= 3);
@@ -1509,6 +1531,13 @@ export function AccountCenter() {
expiryInfo && (showExpiringSoon || showExpiredReminder) expiryInfo && (showExpiringSoon || showExpiredReminder)
? `${formatTime(expiryInfo.raw, locale)} · ${copy.daysLeft.replace("{days}", String(Math.max(expiryInfo.daysLeft, 0)))}` ? `${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(() => { useEffect(() => {
if (!showOverlay || !canOpenCheckoutOverlay) return; if (!showOverlay || !canOpenCheckoutOverlay) return;
@@ -2667,7 +2696,7 @@ export function AccountCenter() {
/> />
<InfoRow <InfoRow
icon={Clock} icon={Clock}
label={copy.renewalDate} label={expiryLabel}
value={proExpiry} value={proExpiry}
isPrimary isPrimary
/> />
@@ -2676,6 +2705,11 @@ export function AccountCenter() {
label={copy.authResult} label={copy.authResult}
value={backend?.authenticated ? copy.passed : copy.restricted} 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> </section>
</div> </div>
@@ -40,6 +40,14 @@ function normalizeMarketValue(value?: number | null) {
return Math.max(0, Math.min(1, numeric)); 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 }) { function WeatherIcon({ emoji, size = 32 }: { emoji: string; size?: number }) {
if (emoji === "☀️") return <Sun size={size} color="#facc15" />; if (emoji === "☀️") return <Sun size={size} color="#facc15" />;
if (emoji === "⛅" || emoji === "🌤️") if (emoji === "⛅" || emoji === "🌤️")
@@ -259,9 +267,10 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
backgroundColor: "rgba(234, 179, 8, 0.05)", backgroundColor: "rgba(234, 179, 8, 0.05)",
borderColor: "rgba(234, 179, 8, 0.8)", borderColor: "rgba(234, 179, 8, 0.8)",
borderWidth: 2, borderWidth: 2,
data: todayChartData.datasets.mgmHourlyPoints, data: todayChartData.datasets.mgmHourlySeries,
fill: false, fill: false,
label: locale === "en-US" ? "MGM Forecast" : "MGM 预测", label: locale === "en-US" ? "MGM Forecast" : "MGM 预测",
parsing: false,
pointHoverRadius: 6, pointHoverRadius: 6,
pointRadius: 3, pointRadius: 3,
spanGaps: true, spanGaps: true,
@@ -272,9 +281,10 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
backgroundColor: "rgba(52, 211, 153, 0.05)", backgroundColor: "rgba(52, 211, 153, 0.05)",
borderColor: "rgba(52, 211, 153, 0.6)", borderColor: "rgba(52, 211, 153, 0.6)",
borderWidth: 1.5, borderWidth: 1.5,
data: todayChartData.datasets.debPast, data: todayChartData.datasets.debPastSeries,
fill: true, fill: true,
label: locale === "en-US" ? "DEB Forecast" : "DEB 预测", label: locale === "en-US" ? "DEB Forecast" : "DEB 预测",
parsing: false,
pointHoverRadius: 3, pointHoverRadius: 3,
pointRadius: 0, pointRadius: 0,
tension: 0.3, tension: 0.3,
@@ -283,9 +293,10 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
borderColor: "rgba(52, 211, 153, 0.35)", borderColor: "rgba(52, 211, 153, 0.35)",
borderDash: [5, 3], borderDash: [5, 3],
borderWidth: 1.5, borderWidth: 1.5,
data: todayChartData.datasets.debFuture, data: todayChartData.datasets.debFutureSeries,
fill: false, fill: false,
label: locale === "en-US" ? "DEB Forecast" : "DEB 预测", label: locale === "en-US" ? "DEB Forecast" : "DEB 预测",
parsing: false,
pointRadius: 0, pointRadius: 0,
tension: 0.3, tension: 0.3,
}); });
@@ -295,43 +306,45 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
backgroundColor: "#22d3ee", backgroundColor: "#22d3ee",
borderColor: "#22d3ee", borderColor: "#22d3ee",
borderWidth: 0, borderWidth: 0,
data: todayChartData.datasets.metarPoints, data: todayChartData.datasets.metarSeries,
fill: false, fill: false,
label: label:
todayChartData.observationLabel || todayChartData.observationLabel ||
(locale === "en-US" ? "Observation" : "观测实况"), (locale === "en-US" ? "Observation" : "观测实况"),
order: 0, order: 0,
parsing: false,
pointHoverRadius: 7, pointHoverRadius: 7,
pointRadius: 5, pointRadius: 5,
showLine: false,
}); });
if ( if (todayChartData.datasets.airportMetarSeries?.length > 0) {
todayChartData.datasets.airportMetarPoints?.some((value) => value != null)
) {
datasets.push({ datasets.push({
backgroundColor: "#60a5fa", backgroundColor: "#60a5fa",
borderColor: "#60a5fa", borderColor: "#60a5fa",
borderWidth: 1, borderWidth: 1,
data: todayChartData.datasets.airportMetarPoints, data: todayChartData.datasets.airportMetarSeries,
fill: false, fill: false,
label: label:
locale === "en-US" ? "Airport METAR" : "机场 METAR", locale === "en-US" ? "Airport METAR" : "机场 METAR",
order: 0, order: 0,
parsing: false,
pointHoverRadius: 6, pointHoverRadius: 6,
pointRadius: 4, pointRadius: 4,
showLine: false, showLine: false,
}); });
} }
if (todayChartData.datasets.mgmPoints.some((value) => value != null)) { if (todayChartData.datasets.mgmSeries?.length > 0) {
datasets.push({ datasets.push({
backgroundColor: "#facc15", backgroundColor: "#facc15",
borderColor: "#facc15", borderColor: "#facc15",
borderWidth: 0, borderWidth: 0,
data: todayChartData.datasets.mgmPoints, data: todayChartData.datasets.mgmSeries,
fill: false, fill: false,
label: locale === "en-US" ? "MGM Observation" : "MGM 实测", label: locale === "en-US" ? "MGM Observation" : "MGM 实测",
order: -1, order: -1,
parsing: false,
pointHoverRadius: 9, pointHoverRadius: 9,
pointRadius: 7, pointRadius: 7,
showLine: false, showLine: false,
@@ -346,9 +359,10 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
borderColor: "rgba(99, 102, 241, 0.2)", borderColor: "rgba(99, 102, 241, 0.2)",
borderDash: [2, 4], borderDash: [2, 4],
borderWidth: 1, borderWidth: 1,
data: todayChartData.datasets.temps, data: todayChartData.datasets.tempsSeries,
fill: false, fill: false,
label: locale === "en-US" ? "OM Raw" : "OM 原始", label: locale === "en-US" ? "OM Raw" : "OM 原始",
parsing: false,
pointRadius: 0, pointRadius: 0,
tension: 0.3, tension: 0.3,
}); });
@@ -358,10 +372,11 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
backgroundColor: "#f59e0b", backgroundColor: "#f59e0b",
borderColor: "#f59e0b", borderColor: "#f59e0b",
borderWidth: 0, borderWidth: 0,
data: todayChartData.datasets.tafCurrentMarkerPoints, data: todayChartData.datasets.tafCurrentMarkerSeries,
fill: false, fill: false,
label: locale === "en-US" ? "Current TAF" : "当前 TAF", label: locale === "en-US" ? "Current TAF" : "当前 TAF",
order: -3, order: -3,
parsing: false,
pointHoverRadius: 8, pointHoverRadius: 8,
pointRadius: 6, pointRadius: 6,
showLine: false, showLine: false,
@@ -370,10 +385,11 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
backgroundColor: "rgba(250, 204, 21, 0.72)", backgroundColor: "rgba(250, 204, 21, 0.72)",
borderColor: "rgba(250, 204, 21, 0.72)", borderColor: "rgba(250, 204, 21, 0.72)",
borderWidth: 0, borderWidth: 0,
data: todayChartData.datasets.tafPeakWindowMarkerPoints, data: todayChartData.datasets.tafPeakWindowMarkerSeries,
fill: false, fill: false,
label: locale === "en-US" ? "Peak-window TAF" : "峰值窗口 TAF", label: locale === "en-US" ? "Peak-window TAF" : "峰值窗口 TAF",
order: -2, order: -2,
parsing: false,
pointHoverRadius: 7, pointHoverRadius: 7,
pointRadius: 4, pointRadius: 4,
showLine: false, showLine: false,
@@ -382,10 +398,11 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
backgroundColor: "#f59e0b", backgroundColor: "#f59e0b",
borderColor: "#f59e0b", borderColor: "#f59e0b",
borderWidth: 0, borderWidth: 0,
data: todayChartData.datasets.tafMarkerPoints, data: todayChartData.datasets.tafMarkerSeries,
fill: false, fill: false,
label: locale === "en-US" ? "TAF Timing" : "TAF 时段", label: locale === "en-US" ? "TAF Timing" : "TAF 时段",
order: -4, order: -4,
parsing: false,
pointHoverRadius: 0, pointHoverRadius: 0,
pointRadius: 0, pointRadius: 0,
showLine: false, showLine: false,
@@ -395,10 +412,10 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
return { return {
data: { data: {
datasets, datasets,
labels: todayChartData.times, labels: [],
}, },
options: { options: {
interaction: { intersect: false, mode: "index" }, interaction: { intersect: false, mode: "nearest" },
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { plugins: {
legend: { legend: {
@@ -423,8 +440,15 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
borderColor: "rgba(34, 211, 238, 0.2)", borderColor: "rgba(34, 211, 238, 0.2)",
borderWidth: 1, borderWidth: 1,
callbacks: { callbacks: {
title: (items) => {
const rawX = items?.[0]?.parsed?.x;
return rawX != null ? formatMinuteAxisLabel(Number(rawX)) : "";
},
label: (ctx) => { label: (ctx) => {
const label = String(ctx.dataset.label || ""); const label = String(ctx.dataset.label || "");
const raw = ctx.raw as
| { marker?: { summary?: string; markerType?: string; displayType?: string; isCurrent?: boolean; isPeakWindow?: boolean } }
| undefined;
if ( if (
label === "TAF Timing" || label === "TAF Timing" ||
label === "TAF 时段" || label === "TAF 时段" ||
@@ -433,10 +457,13 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
label === "Peak-window TAF" || label === "Peak-window TAF" ||
label === "峰值窗口 TAF" label === "峰值窗口 TAF"
) { ) {
const marker = (todayChartData.tafMarkers || []).find( const marker = raw?.marker;
(item) => item.index === ctx.dataIndex,
);
if (!marker) return label; if (!marker) return label;
const markerType = String(marker.markerType || "");
const displayType = String(
marker.displayType || marker.markerType || "",
);
const summary = String(marker.summary || "");
const prefix = const prefix =
marker.isCurrent && marker.isPeakWindow marker.isCurrent && marker.isPeakWindow
? locale === "en-US" ? locale === "en-US"
@@ -451,10 +478,9 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
? "Peak-window TAF" ? "Peak-window TAF"
: "峰值窗口 TAF" : "峰值窗口 TAF"
: label; : label;
return `${prefix}: ${String(marker.summary || "").replace( return `${prefix}: ${
marker.markerType, markerType ? summary.replace(markerType, displayType) : summary
marker.displayType || marker.markerType, }`;
)}`;
} }
const value = ctx.parsed.y; const value = ctx.parsed.y;
if (value == null) return label; if (value == null) return label;
@@ -466,12 +492,24 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
responsive: true, responsive: true,
scales: { scales: {
x: { x: {
max: todayChartData.xMax,
min: todayChartData.xMin,
grid: { color: "rgba(255,255,255,0.04)" }, grid: { color: "rgba(255,255,255,0.04)" },
type: "linear",
ticks: { ticks: {
callback: (_value, index) => callback: (value) => {
typeof index === "number" && index % 3 === 0 const num = Number(value);
? todayChartData.times[index] 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", color: "#64748b",
font: { family: "Inter", size: 10 }, font: { family: "Inter", size: 10 },
maxRotation: 0, maxRotation: 0,
+5 -1
View File
@@ -38,7 +38,11 @@ export function HeaderBar() {
const accountAria = isAuthenticated const accountAria = isAuthenticated
? t("header.accountAria") ? t("header.accountAria")
: t("header.signInAria"); : 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( const isTrialPlan = /trial/i.test(
String(store.proAccess.subscriptionPlanCode || ""), String(store.proAccess.subscriptionPlanCode || ""),
); );
+12
View File
@@ -81,6 +81,8 @@ function getInitialProAccessState(): ProAccessState {
subscriptionActive: false, subscriptionActive: false,
subscriptionPlanCode: null, subscriptionPlanCode: null,
subscriptionExpiresAt: null, subscriptionExpiresAt: null,
subscriptionTotalExpiresAt: null,
subscriptionQueuedDays: 0,
points: 0, points: 0,
error: null, error: null,
}; };
@@ -593,6 +595,8 @@ export function DashboardStoreProvider({
subscription_active?: boolean | null; subscription_active?: boolean | null;
subscription_plan_code?: string | null; subscription_plan_code?: string | null;
subscription_expires_at?: string | null; subscription_expires_at?: string | null;
subscription_total_expires_at?: string | null;
subscription_queued_days?: number | null;
points?: number; points?: number;
}; };
setProAccess({ setProAccess({
@@ -602,6 +606,12 @@ export function DashboardStoreProvider({
subscriptionActive: payload.subscription_active === true, subscriptionActive: payload.subscription_active === true,
subscriptionPlanCode: payload.subscription_plan_code ?? null, subscriptionPlanCode: payload.subscription_plan_code ?? null,
subscriptionExpiresAt: payload.subscription_expires_at ?? 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, points: payload.points ?? 0,
error: null, error: null,
}); });
@@ -613,6 +623,8 @@ export function DashboardStoreProvider({
subscriptionActive: false, subscriptionActive: false,
subscriptionPlanCode: null, subscriptionPlanCode: null,
subscriptionExpiresAt: null, subscriptionExpiresAt: null,
subscriptionTotalExpiresAt: null,
subscriptionQueuedDays: 0,
points: 0, points: 0,
error: String(error), error: String(error),
}); });
+2
View File
@@ -516,6 +516,8 @@ export interface ProAccessState {
subscriptionActive: boolean; subscriptionActive: boolean;
subscriptionPlanCode: string | null; subscriptionPlanCode: string | null;
subscriptionExpiresAt: string | null; subscriptionExpiresAt: string | null;
subscriptionTotalExpiresAt: string | null;
subscriptionQueuedDays: number;
points: number; points: number;
error: string | null; error: string | null;
} }
+121 -27
View File
@@ -248,6 +248,70 @@ function hmToMinutes(value?: string | null) {
return hour * 60 + minute; 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( function interpolateSeriesAtMinutes(
times: string[], times: string[],
values: Array<number | null | undefined>, values: Array<number | null | undefined>,
@@ -516,10 +580,7 @@ export function getTemperatureChartData(
if (!times.length) return null; if (!times.length) return null;
const currentHour = detail.local_time const currentIndex = findNearestTimeIndex(times, detail.local_time);
? `${detail.local_time.split(":")[0]}:00`
: null;
const currentIndex = currentHour ? times.indexOf(currentHour) : -1;
const omMax = detail.forecast?.today_high; const omMax = detail.forecast?.today_high;
const debMax = detail.deb?.prediction; const debMax = detail.deb?.prediction;
const offset = const offset =
@@ -582,11 +643,7 @@ export function getTemperatureChartData(
const metarPoints = new Array(times.length).fill(null); const metarPoints = new Array(times.length).fill(null);
observationSource.forEach((item) => { observationSource.forEach((item) => {
const parts = String(item.time || "").split(":"); const index = findNearestTimeIndex(times, String(item.time || ""));
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 temp = item.temp ?? null; const temp = item.temp ?? null;
if (index >= 0 && temp != null) { if (index >= 0 && temp != null) {
const existing = metarPoints[index]; const existing = metarPoints[index];
@@ -598,11 +655,7 @@ export function getTemperatureChartData(
}); });
const airportMetarPoints = new Array(times.length).fill(null); const airportMetarPoints = new Array(times.length).fill(null);
airportMetarSource.forEach((item) => { airportMetarSource.forEach((item) => {
const parts = String(item.time || "").split(":"); const index = findNearestTimeIndex(times, String(item.time || ""));
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 temp = item.temp ?? null; const temp = item.temp ?? null;
if (index >= 0 && temp != null) { if (index >= 0 && temp != null) {
const existing = airportMetarPoints[index]; const existing = airportMetarPoints[index];
@@ -617,24 +670,16 @@ export function getTemperatureChartData(
detail.mgm?.temp != null && detail.mgm?.temp != null &&
detail.mgm?.time detail.mgm?.time
) { ) {
const match = detail.mgm.time.match(/T?(\d{2}):(\d{2})/); const index = findNearestTimeIndex(times, detail.mgm.time);
if (match) { if (index >= 0) {
let hour = Number.parseInt(match[1], 10); mgmPoints[index] = detail.mgm.temp;
const key = `${String(hour).padStart(2, "0")}:00`;
const index = times.indexOf(key);
if (index >= 0) {
mgmPoints[index] = detail.mgm.temp;
}
} }
} }
const mgmHourlyPoints = new Array(times.length).fill(null); const mgmHourlyPoints = new Array(times.length).fill(null);
let hasMgmHourly = false; let hasMgmHourly = false;
detail.mgm?.hourly?.forEach((item) => { detail.mgm?.hourly?.forEach((item) => {
const match = String(item.time || "").match(/T?(\d{2}):(\d{2})/); const index = findNearestTimeIndex(times, String(item.time || ""));
if (!match) return;
const key = `${match[1]}:00`;
const index = times.indexOf(key);
if (index >= 0) { if (index >= 0) {
mgmHourlyPoints[index] = item.temp ?? null; mgmHourlyPoints[index] = item.temp ?? null;
hasMgmHourly = true; hasMgmHourly = true;
@@ -723,7 +768,7 @@ export function getTemperatureChartData(
const tafMarkers = tafMarkersRaw const tafMarkers = tafMarkersRaw
.map((marker) => { .map((marker) => {
const labelTime = String(marker?.label_time || "").trim(); const labelTime = String(marker?.label_time || "").trim();
const index = times.indexOf(labelTime); const index = findNearestTimeIndex(times, labelTime);
if (index >= 0) { if (index >= 0) {
tafMarkerPoints[index] = tafMarkerValue; 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 { return {
datasets: { datasets: {
airportMetarPoints, airportMetarPoints,
airportMetarSeries,
debFuture, debFuture,
debFutureSeries,
debPast, debPast,
debPastSeries,
hasMgmHourly, hasMgmHourly,
metarPoints, metarPoints,
metarSeries,
mgmHourlyPoints, mgmHourlyPoints,
mgmHourlySeries,
mgmPoints, mgmPoints,
mgmSeries,
offset, offset,
tafCurrentMarkerPoints, tafCurrentMarkerPoints,
tafCurrentMarkerSeries,
tafMarkerPoints, tafMarkerPoints,
tafMarkerSeries,
tafPeakWindowMarkerPoints, tafPeakWindowMarkerPoints,
tafPeakWindowMarkerSeries,
temps, temps,
tempsSeries,
}, },
observationLabel: observationLabel:
observationCode === "noaa" && observationCode === "noaa" &&
@@ -918,7 +1009,10 @@ export function getTemperatureChartData(
max, max,
min, min,
tafMarkers, tafMarkers,
tickLabels: buildTemperatureTickLabels(times),
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}") logger.warning(f"supabase subscription query error user_id={user_id}: {exc}")
return None 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( def _query_latest_subscription_any_status(
self, self,
user_id: str, user_id: str,
@@ -453,6 +516,51 @@ class SupabaseEntitlementService:
) -> Optional[Dict[str, object]]: ) -> Optional[Dict[str, object]]:
return self._query_latest_subscription_any_status(user_id) 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( def has_active_subscription(
self, self,
user_id: str, user_id: str,
+22 -1
View File
@@ -561,6 +561,9 @@ async def auth_me(request: Request):
subscription_plan_code = None subscription_plan_code = None
subscription_starts_at = None subscription_starts_at = None
subscription_expires_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: if SUPABASE_ENTITLEMENT.enabled and user_id:
try: try:
@@ -593,16 +596,31 @@ async def auth_me(request: Request):
latest_known_subscription = ( latest_known_subscription = (
SUPABASE_ENTITLEMENT.get_latest_subscription_any_status(user_id) 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) 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_plan_code = latest_known_subscription.get("plan_code")
subscription_starts_at = latest_known_subscription.get("starts_at") subscription_starts_at = latest_known_subscription.get("starts_at")
subscription_expires_at = latest_known_subscription.get("expires_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: except Exception:
subscription_active = None subscription_active = None
subscription_plan_code = None subscription_plan_code = None
subscription_starts_at = None subscription_starts_at = None
subscription_expires_at = None subscription_expires_at = None
subscription_total_expires_at = None
subscription_queued_days = 0
subscription_queued_count = 0
points = _resolve_auth_points(request) points = _resolve_auth_points(request)
weekly_profile = _resolve_weekly_profile(request) weekly_profile = _resolve_weekly_profile(request)
@@ -629,6 +647,9 @@ async def auth_me(request: Request):
"subscription_plan_code": subscription_plan_code, "subscription_plan_code": subscription_plan_code,
"subscription_starts_at": subscription_starts_at, "subscription_starts_at": subscription_starts_at,
"subscription_expires_at": subscription_expires_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,
} }