Show queued subscription access and fix forecast time plotting
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 || ""),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user