Fix runway chart collapse and ops cleanup
This commit is contained in:
@@ -15,7 +15,11 @@ import {
|
|||||||
} from "recharts";
|
} from "recharts";
|
||||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||||
import { TemperatureTooltipContent } from "@/components/dashboard/scan-terminal/TemperatureTooltipContent";
|
import { TemperatureTooltipContent } from "@/components/dashboard/scan-terminal/TemperatureTooltipContent";
|
||||||
import type { EvidenceSeries, ProbabilityOverlay } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
|
import {
|
||||||
|
getTemperatureSeriesForRunwayDetailsMode,
|
||||||
|
type EvidenceSeries,
|
||||||
|
type ProbabilityOverlay,
|
||||||
|
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
|
||||||
|
|
||||||
type CityThreshold = {
|
type CityThreshold = {
|
||||||
threshold: number;
|
threshold: number;
|
||||||
@@ -120,23 +124,25 @@ export function TemperatureChartCanvas({
|
|||||||
const individualRunwaySeriesCount = chartSeries.filter(
|
const individualRunwaySeriesCount = chartSeries.filter(
|
||||||
(series) => series.key.startsWith("runway_") && series.key !== "runway_max",
|
(series) => series.key.startsWith("runway_") && series.key !== "runway_max",
|
||||||
).length;
|
).length;
|
||||||
|
const collapsedRunwaySeries = getTemperatureSeriesForRunwayDetailsMode(
|
||||||
|
row?.city || "",
|
||||||
|
chartSeries,
|
||||||
|
false,
|
||||||
|
);
|
||||||
const canToggleRunwayDetails =
|
const canToggleRunwayDetails =
|
||||||
hasRunwayData &&
|
hasRunwayData &&
|
||||||
individualRunwaySeriesCount > 1 &&
|
individualRunwaySeriesCount > 1 &&
|
||||||
chartSeries.some((series) => series.key === "runway_max");
|
collapsedRunwaySeries.length < chartSeries.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("relative flex flex-1 flex-col p-2", compact ? "min-h-[120px]" : "min-h-[240px]")}>
|
<div className={clsx("relative flex flex-1 flex-col p-2", compact ? "min-h-[120px]" : "min-h-[240px]")}>
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 px-3 py-1.5 text-[11px] border-b border-[#e2e8f0] bg-white">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 px-3 py-1.5 text-[11px] border-b border-[#e2e8f0] bg-white">
|
||||||
{chartSeries.length > 1 &&
|
{chartSeries.length > 1 &&
|
||||||
chartSeries
|
getTemperatureSeriesForRunwayDetailsMode(
|
||||||
.filter((s) => {
|
row?.city || "",
|
||||||
const isIndividualRunway = s.key.startsWith("runway_") && s.key !== "runway_max";
|
chartSeries,
|
||||||
if (showRunwayDetails) {
|
showRunwayDetails,
|
||||||
return s.key !== "runway_max";
|
)
|
||||||
}
|
|
||||||
return !isIndividualRunway;
|
|
||||||
})
|
|
||||||
.map((s) => (
|
.map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s.key}
|
key={s.key}
|
||||||
|
|||||||
+58
@@ -850,6 +850,64 @@ export function runTests() {
|
|||||||
"runway header should prefer runway-history current temp even when the latest detail payload lacks runway_obs snapshot",
|
"runway header should prefer runway-history current temp even when the latest detail payload lacks runway_obs snapshot",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const wuhanRunwayChart = __buildTemperatureChartDataForTest(
|
||||||
|
{
|
||||||
|
city: "wuhan",
|
||||||
|
local_date: "2026-05-27",
|
||||||
|
local_time: "13:54",
|
||||||
|
tz_offset_seconds: 8 * 60 * 60,
|
||||||
|
temp_symbol: "°C",
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
localTime: "13:54",
|
||||||
|
times: ["00:00", "12:00", "18:00", "23:00"],
|
||||||
|
temps: [22.0, 30.0, 29.0, 25.0],
|
||||||
|
runwayPlateHistory: {
|
||||||
|
"04/22": [
|
||||||
|
{ time: "13:52", temp: 24.0 },
|
||||||
|
{ time: "13:54", temp: 24.2 },
|
||||||
|
],
|
||||||
|
"05L/23R": [
|
||||||
|
{ time: "13:52", temp: 25.0 },
|
||||||
|
{ time: "13:54", temp: 25.5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
runwayBandHistory: [
|
||||||
|
{ time: "2026-05-27T13:52:00+08:00", low_temp: 24.0, high_temp: 25.0, avg_temp: 24.5 },
|
||||||
|
{ time: "2026-05-27T13:54:00+08:00", low_temp: 24.2, high_temp: 25.5, avg_temp: 24.9 },
|
||||||
|
],
|
||||||
|
} as any,
|
||||||
|
"1D",
|
||||||
|
);
|
||||||
|
const wuhanCollapsedRunwaySeries = __getActiveTemperatureSeriesForTest(
|
||||||
|
"wuhan",
|
||||||
|
wuhanRunwayChart.series,
|
||||||
|
{},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
wuhanCollapsedRunwaySeries.some((item: any) => item.key === runwayKey("04/22")),
|
||||||
|
"collapsed runway view should keep the settlement runway series",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!wuhanCollapsedRunwaySeries.some((item: any) => item.key === runwayKey("05L/23R")),
|
||||||
|
"collapsed runway view should hide auxiliary runway detail series",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!wuhanCollapsedRunwaySeries.some((item: any) => item.key === "runway_max"),
|
||||||
|
"collapsed runway view should not replace the settlement runway with runway max",
|
||||||
|
);
|
||||||
|
const wuhanCollapsedWithSettlementHidden = __getActiveTemperatureSeriesForTest(
|
||||||
|
"wuhan",
|
||||||
|
wuhanRunwayChart.series,
|
||||||
|
{ [runwayKey("04/22")]: false },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!wuhanCollapsedWithSettlementHidden.some((item: any) => item.key === "runway_max"),
|
||||||
|
"hiding the settlement runway should not reveal runway max in collapsed runway view",
|
||||||
|
);
|
||||||
|
|
||||||
const newYorkMetrics = __getObservationDisplayMetricsForTest(
|
const newYorkMetrics = __getObservationDisplayMetricsForTest(
|
||||||
{
|
{
|
||||||
city: "new york",
|
city: "new york",
|
||||||
|
|||||||
@@ -115,21 +115,44 @@ function getVisibleTemperatureSeries(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActiveTemperatureSeries(
|
function isIndividualRunwaySeriesKey(seriesKey: string) {
|
||||||
|
return seriesKey.startsWith("runway_") && seriesKey !== "runway_max";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSettlementRunwaySeriesKey(city: string, seriesKey: string) {
|
||||||
|
if (!isIndividualRunwaySeriesKey(seriesKey)) return false;
|
||||||
|
const cityKey = normalizeCityKey(city);
|
||||||
|
const settlementPairs = SETTLEMENT_RUNWAY_PAIRS[cityKey] || [];
|
||||||
|
if (!settlementPairs.length) return false;
|
||||||
|
const normalized = seriesKey
|
||||||
|
.replace(/^runway_/, "")
|
||||||
|
.split("_")
|
||||||
|
.map(normalizeRunwayLabel)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort()
|
||||||
|
.join("/");
|
||||||
|
return settlementPairs.some((pair) => pairKey(pair) === normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTemperatureSeriesForRunwayDetailsMode(
|
||||||
city: string,
|
city: string,
|
||||||
chartSeries: EvidenceSeries[],
|
series: EvidenceSeries[],
|
||||||
userToggledKeys: Record<string, boolean>,
|
|
||||||
showRunwayDetails: boolean,
|
showRunwayDetails: boolean,
|
||||||
) {
|
) {
|
||||||
const rawVisible = getVisibleTemperatureSeries(city, chartSeries, userToggledKeys);
|
const hasRunwayMax = series.some((item) => item.key === "runway_max");
|
||||||
const hasRunwayMax = rawVisible.some((item) => item.key === "runway_max");
|
const hasSettlementRunway = series.some((item) =>
|
||||||
|
isSettlementRunwaySeriesKey(city, item.key),
|
||||||
|
);
|
||||||
|
|
||||||
return rawVisible.filter((item) => {
|
return series.filter((item) => {
|
||||||
const isIndividualRunway =
|
const isIndividualRunway = isIndividualRunwaySeriesKey(item.key);
|
||||||
item.key.startsWith("runway_") && item.key !== "runway_max";
|
|
||||||
if (showRunwayDetails) {
|
if (showRunwayDetails) {
|
||||||
return item.key !== "runway_max";
|
return item.key !== "runway_max";
|
||||||
}
|
}
|
||||||
|
if (hasSettlementRunway) {
|
||||||
|
if (item.key === "runway_max") return false;
|
||||||
|
return !isIndividualRunway || isSettlementRunwaySeriesKey(city, item.key);
|
||||||
|
}
|
||||||
if (!hasRunwayMax) {
|
if (!hasRunwayMax) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -137,6 +160,20 @@ function getActiveTemperatureSeries(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getActiveTemperatureSeries(
|
||||||
|
city: string,
|
||||||
|
chartSeries: EvidenceSeries[],
|
||||||
|
userToggledKeys: Record<string, boolean>,
|
||||||
|
showRunwayDetails: boolean,
|
||||||
|
) {
|
||||||
|
const modeSeries = getTemperatureSeriesForRunwayDetailsMode(
|
||||||
|
city,
|
||||||
|
chartSeries,
|
||||||
|
showRunwayDetails,
|
||||||
|
);
|
||||||
|
return getVisibleTemperatureSeries(city, modeSeries, userToggledKeys);
|
||||||
|
}
|
||||||
|
|
||||||
function buildRunwayPlates(
|
function buildRunwayPlates(
|
||||||
amos: AmosData | null | undefined,
|
amos: AmosData | null | undefined,
|
||||||
row: ScanOpportunityRow | null,
|
row: ScanOpportunityRow | null,
|
||||||
@@ -2156,6 +2193,7 @@ export {
|
|||||||
buildRunwayPlates,
|
buildRunwayPlates,
|
||||||
fetchHourlyForecastForCity,
|
fetchHourlyForecastForCity,
|
||||||
getActiveTemperatureSeries,
|
getActiveTemperatureSeries,
|
||||||
|
getTemperatureSeriesForRunwayDetailsMode,
|
||||||
getLiveObservationLabels,
|
getLiveObservationLabels,
|
||||||
getObservationDisplayMetrics,
|
getObservationDisplayMetrics,
|
||||||
getVisibleTemperatureSeries,
|
getVisibleTemperatureSeries,
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
-- PolyWeather production remediation from Supabase MCP diagnostics.
|
||||||
|
-- Intended for Supabase SQL Editor or a privileged database session.
|
||||||
|
-- The Supabase MCP session available to Codex was read-only, so this file
|
||||||
|
-- contains the exact write-side changes to run with project write access.
|
||||||
|
|
||||||
|
-- 1) Restrict SECURITY DEFINER functions from public RPC execution.
|
||||||
|
-- These functions are used as event/row triggers and should not be callable
|
||||||
|
-- directly by anon/authenticated clients unless intentionally exposed.
|
||||||
|
revoke execute on function public.rls_auto_enable() from public, anon, authenticated;
|
||||||
|
revoke execute on function public.sync_profile_from_auth() from public, anon, authenticated;
|
||||||
|
|
||||||
|
-- 2) Remove broad direct public-table privileges from client roles.
|
||||||
|
-- RLS currently blocks row access because no policies exist, but these grants
|
||||||
|
-- create a large blast radius if a permissive policy is added later.
|
||||||
|
revoke all privileges on all tables in schema public from anon, authenticated;
|
||||||
|
revoke all privileges on all sequences in schema public from anon, authenticated;
|
||||||
|
|
||||||
|
-- Keep future objects created by this role from inheriting broad access.
|
||||||
|
alter default privileges in schema public revoke all on tables from anon, authenticated;
|
||||||
|
alter default privileges in schema public revoke all on sequences from anon, authenticated;
|
||||||
|
|
||||||
|
-- 3) Add missing foreign-key helper indexes reported by Supabase advisor.
|
||||||
|
create index if not exists idx_entitlement_events_user_id
|
||||||
|
on public.entitlement_events(user_id);
|
||||||
|
|
||||||
|
create index if not exists idx_payments_user_id
|
||||||
|
on public.payments(user_id);
|
||||||
|
|
||||||
|
create index if not exists idx_wallet_link_challenges_user_id
|
||||||
|
on public.wallet_link_challenges(user_id);
|
||||||
|
|
||||||
|
-- 4) Normalize stale business state observed in read-only diagnostics.
|
||||||
|
update public.subscriptions
|
||||||
|
set status = 'expired',
|
||||||
|
updated_at = now()
|
||||||
|
where status = 'active'
|
||||||
|
and expires_at <= now();
|
||||||
|
|
||||||
|
update public.payment_intents
|
||||||
|
set status = 'expired',
|
||||||
|
updated_at = now()
|
||||||
|
where status in ('created', 'submitted')
|
||||||
|
and expires_at <= now();
|
||||||
|
|
||||||
|
delete from public.wallet_link_challenges
|
||||||
|
where consumed_at is null
|
||||||
|
and expires_at <= now();
|
||||||
|
|
||||||
|
-- Backfill profiles for auth users created while the auth trigger was absent
|
||||||
|
-- or failed. Existing profiles are not modified.
|
||||||
|
insert into public.profiles (id, email)
|
||||||
|
select u.id, coalesce(u.email, '')
|
||||||
|
from auth.users u
|
||||||
|
left join public.profiles p on p.id = u.id
|
||||||
|
where p.id is null;
|
||||||
|
|
||||||
|
analyze public.entitlement_events;
|
||||||
|
analyze public.payments;
|
||||||
|
analyze public.wallet_link_challenges;
|
||||||
|
analyze public.subscriptions;
|
||||||
|
analyze public.payment_intents;
|
||||||
|
analyze public.profiles;
|
||||||
|
|
||||||
|
-- 5) Verification queries. Expected after this script:
|
||||||
|
-- - exposed_security_definer_functions = 0
|
||||||
|
-- - public_client_table_grants = 0
|
||||||
|
-- - missing_public_fk_indexes = 0
|
||||||
|
-- - active_expired_subscriptions = 0
|
||||||
|
-- - open_expired_payment_intents = 0
|
||||||
|
-- - expired_unconsumed_wallet_challenges = 0
|
||||||
|
-- - auth_users_without_profile = 0
|
||||||
|
select 'exposed_security_definer_functions' as check_name,
|
||||||
|
count(*)::bigint as count
|
||||||
|
from pg_proc p
|
||||||
|
join pg_namespace n on n.oid = p.pronamespace
|
||||||
|
where n.nspname = 'public'
|
||||||
|
and p.proname in ('rls_auto_enable', 'sync_profile_from_auth')
|
||||||
|
and p.prosecdef
|
||||||
|
and (
|
||||||
|
has_function_privilege('anon', p.oid, 'execute')
|
||||||
|
or has_function_privilege('authenticated', p.oid, 'execute')
|
||||||
|
)
|
||||||
|
union all
|
||||||
|
select 'public_client_table_grants',
|
||||||
|
count(*)::bigint
|
||||||
|
from pg_class c
|
||||||
|
join pg_namespace n on n.oid = c.relnamespace
|
||||||
|
cross join lateral aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) a
|
||||||
|
left join pg_roles grantee on grantee.oid = a.grantee
|
||||||
|
where n.nspname = 'public'
|
||||||
|
and c.relkind = 'r'
|
||||||
|
and coalesce(grantee.rolname, 'PUBLIC') in ('anon', 'authenticated')
|
||||||
|
union all
|
||||||
|
select 'missing_public_fk_indexes',
|
||||||
|
count(*)::bigint
|
||||||
|
from (
|
||||||
|
select con.conrelid, con.conkey
|
||||||
|
from pg_constraint con
|
||||||
|
join pg_namespace n on n.oid = con.connamespace
|
||||||
|
where con.contype = 'f'
|
||||||
|
and n.nspname = 'public'
|
||||||
|
and not exists (
|
||||||
|
select 1
|
||||||
|
from pg_index i
|
||||||
|
where i.indrelid = con.conrelid
|
||||||
|
and i.indisvalid
|
||||||
|
and i.indisready
|
||||||
|
and (
|
||||||
|
select array_agg(k order by ord)
|
||||||
|
from unnest(i.indkey) with ordinality as x(k, ord)
|
||||||
|
where ord <= array_length(con.conkey, 1)
|
||||||
|
) = con.conkey
|
||||||
|
)
|
||||||
|
) missing_fks
|
||||||
|
union all
|
||||||
|
select 'active_expired_subscriptions',
|
||||||
|
count(*)::bigint
|
||||||
|
from public.subscriptions
|
||||||
|
where status = 'active'
|
||||||
|
and expires_at <= now()
|
||||||
|
union all
|
||||||
|
select 'open_expired_payment_intents',
|
||||||
|
count(*)::bigint
|
||||||
|
from public.payment_intents
|
||||||
|
where status in ('created', 'submitted')
|
||||||
|
and expires_at <= now()
|
||||||
|
union all
|
||||||
|
select 'expired_unconsumed_wallet_challenges',
|
||||||
|
count(*)::bigint
|
||||||
|
from public.wallet_link_challenges
|
||||||
|
where consumed_at is null
|
||||||
|
and expires_at <= now()
|
||||||
|
union all
|
||||||
|
select 'auth_users_without_profile',
|
||||||
|
count(*)::bigint
|
||||||
|
from auth.users u
|
||||||
|
left join public.profiles p on p.id = u.id
|
||||||
|
where p.id is null;
|
||||||
@@ -24,7 +24,9 @@ def _cleanup_stale() -> None:
|
|||||||
|
|
||||||
def _start_cleanup() -> None:
|
def _start_cleanup() -> None:
|
||||||
_cleanup_stale()
|
_cleanup_stale()
|
||||||
threading.Timer(_cleanup_interval_sec, _start_cleanup).start()
|
timer = threading.Timer(_cleanup_interval_sec, _start_cleanup)
|
||||||
|
timer.daemon = True
|
||||||
|
timer.start()
|
||||||
|
|
||||||
|
|
||||||
_start_cleanup()
|
_start_cleanup()
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
|
||||||
|
|
||||||
|
def test_online_tracker_cleanup_timer_is_daemon(monkeypatch):
|
||||||
|
timers = []
|
||||||
|
|
||||||
|
class FakeTimer:
|
||||||
|
def __init__(self, interval, callback):
|
||||||
|
self.interval = interval
|
||||||
|
self.callback = callback
|
||||||
|
self.daemon = False
|
||||||
|
self.started = False
|
||||||
|
timers.append(self)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.started = True
|
||||||
|
|
||||||
|
monkeypatch.setattr(threading, "Timer", FakeTimer)
|
||||||
|
sys.modules.pop("src.utils.online_tracker", None)
|
||||||
|
|
||||||
|
importlib.import_module("src.utils.online_tracker")
|
||||||
|
|
||||||
|
assert timers
|
||||||
|
assert timers[0].daemon is True
|
||||||
|
assert timers[0].started is True
|
||||||
Reference in New Issue
Block a user