diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c65a79da..79f95c1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Business state tests + run: npm run test:business + - name: Build run: npm run build diff --git a/frontend/components/dashboard/ScanTerminal.module.css b/frontend/components/dashboard/ScanTerminal.module.css index 483fd743..e4011297 100644 --- a/frontend/components/dashboard/ScanTerminal.module.css +++ b/frontend/components/dashboard/ScanTerminal.module.css @@ -379,285 +379,6 @@ transition: all 0.3s ease; } -/* ── Opportunity Table ── */ -.root :global(.scan-table-container) { - flex: 1; - overflow-y: auto; - display: flex; - flex-direction: column; -} - -.root :global(.scan-table-header) { - display: grid; - grid-template-columns: 48px 200px 160px 1fr 150px 90px 64px 40px; - align-items: center; - padding: 8px 12px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-muted); - border-bottom: 1px solid var(--border-subtle); - position: sticky; - top: 0; - z-index: 1; - background: var(--bg-primary); -} - -.root :global(.scan-table-row) { - display: grid; - grid-template-columns: 48px 200px 160px 1fr 150px 90px 64px 40px; - align-items: center; - background: rgba(13, 17, 23, 0.4); - border: 1px solid rgba(255, 255, 255, 0.05); - border-radius: 8px; - cursor: pointer; - transition: all 0.15s ease; - user-select: none; - padding: 12px; -} - -.root :global(.scan-table-row:hover) { - background: rgba(255, 255, 255, 0.06); - border-color: rgba(255, 255, 255, 0.15); - transform: scale(1.002); -} - -.root :global(.scan-table-row.selected) { - background: rgba(0, 224, 164, 0.08); - border-color: rgba(0, 224, 164, 0.3); - box-shadow: inset 0 0 12px rgba(0, 224, 164, 0.05); -} - -.root :global(.scan-table-row.tradable) { - border-left: 2px solid rgba(0, 224, 164, 0.3); -} - -/* Table cells */ -.root :global(.scan-td) { - display: flex; - align-items: center; -} - -/* Rank */ -.root :global(.scan-rank-circle) { - width: 28px; - height: 28px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - font-weight: 700; - background: rgba(0, 224, 164, 0.12); - color: #4DA3FF; -} - -.root :global(.rank-amber .scan-rank-circle) { - background: rgba(255, 176, 32, 0.12); - color: #F59E0B; -} - -.root :global(.rank-purple .scan-rank-circle) { - background: rgba(123, 97, 255, 0.12); - color: #4DA3FF; -} - -.root :global(.rank-neutral .scan-rank-circle) { - background: rgba(255, 255, 255, 0.06); - color: var(--text-muted); -} - -/* City Cell */ -.root :global(.scan-td-city) { - display: flex; - align-items: center; - gap: 10px; -} - -.root :global(.scan-city-thumb) { - width: 48px; - height: 32px; - border-radius: 6px; - overflow: hidden; - flex-shrink: 0; -} - -.root :global(.scan-city-img) { - width: 100%; - height: 100%; - object-fit: cover; -} - -.root :global(.scan-city-img-placeholder) { - width: 100%; - height: 100%; - background: linear-gradient( - 135deg, - rgba(0, 224, 164, 0.1), - rgba(123, 97, 255, 0.1) - ); -} - -.root :global(.scan-city-info) { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.root :global(.scan-city-name) { - font-size: 13px; - font-weight: 600; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.root :global(.scan-city-sub) { - font-size: 11px; - color: var(--text-muted); -} - -/* Time Cell */ -.root :global(.scan-td-time) { - display: flex; - flex-direction: column; - gap: 4px; -} - -.root :global(.scan-time-text) { - font-size: 12px; - font-weight: 500; - color: var(--text-secondary); - font-variant-numeric: tabular-nums; -} - -/* Status Badge */ -.root :global(.scan-status-badge) { - font-size: 10px; - font-weight: 600; - padding: 2px 8px; - border-radius: 4px; - text-transform: uppercase; - letter-spacing: 0.04em; - width: fit-content; -} - -.root :global(.scan-status-badge.tone-green) { - background: rgba(0, 224, 164, 0.12); - color: #4DA3FF; -} - -.root :global(.scan-status-badge.tone-amber) { - background: rgba(255, 176, 32, 0.12); - color: #F59E0B; -} - -.root :global(.scan-status-badge.tone-purple) { - background: rgba(123, 97, 255, 0.12); - color: #4DA3FF; -} - -.root :global(.scan-status-badge.tone-neutral) { - background: rgba(255, 255, 255, 0.05); - color: var(--text-muted); -} - -/* Mini Probability Chart */ -.root :global(.scan-mini-chart) { - display: flex; - align-items: flex-end; - gap: 2px; - height: 40px; - padding: 0 4px; -} - -.root :global(.scan-mini-bar-col) { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - flex: 1; - height: 100%; - justify-content: flex-end; -} - -.root :global(.scan-mini-bar) { - width: 100%; - min-width: 12px; - max-width: 20px; - border-radius: 2px 2px 0 0; - background: linear-gradient( - to top, - rgba(0, 224, 164, 0.3), - rgba(0, 224, 164, 0.7) - ); - transition: height 0.3s; -} - -.root :global(.scan-mini-bar-label) { - font-size: 9px; - color: var(--text-muted); - font-variant-numeric: tabular-nums; -} - -.root :global(.scan-no-data) { - color: var(--text-muted); - font-size: 12px; -} - -/* Action Cell */ -.root :global(.scan-action-text) { - font-size: 12px; - font-weight: 600; - color: #4DA3FF; - white-space: nowrap; -} - -/* Edge */ -.root :global(.scan-edge-value) { - font-size: 14px; - font-weight: 700; - font-variant-numeric: tabular-nums; -} - -.root :global(.scan-edge-value.positive) { - color: #4DA3FF; -} - -.root :global(.scan-edge-value.neutral) { - color: var(--text-muted); -} - -/* Score Ring */ -.root :global(.scan-score-ring) { - position: relative; - width: 48px; - height: 48px; - display: flex; - align-items: center; - justify-content: center; -} - -.root :global(.scan-score-value) { - position: absolute; - font-size: 14px; - font-weight: 800; -} - -/* Favorite */ -.root :global(.scan-fav-icon) { - color: var(--text-muted); - cursor: pointer; - transition: color 0.2s; -} - -.root :global(.scan-fav-icon:hover) { - color: #F59E0B; -} - /* ── Right: Scan Detail Panel ── */ .root :global(.scan-detail-panel) { width: 360px; @@ -1635,597 +1356,6 @@ -ms-overflow-style: none; } -.root :global(.scan-opportunity-groups) { - display: flex; - flex-direction: column; - gap: 14px; - padding: 14px 18px 18px; -} - -.root :global(.scan-opportunity-group) { - border: 1px solid rgba(90, 123, 166, 0.14); - border-radius: 16px; - overflow: hidden; - background: linear-gradient(180deg, rgba(10, 21, 36, 0.94), rgba(8, 17, 29, 0.94)); -} - -.root :global(.scan-opportunity-group.selected), -.root :global(.scan-opportunity-group:has(.scan-opportunity-item.selected)) { - border-color: rgba(23, 217, 139, 0.42); - box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.18); -} - -.root :global(.scan-opportunity-group-head) { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 16px; - align-items: center; - width: 100%; - padding: 16px 18px 14px; - border: 0; - border-bottom: 1px solid rgba(90, 123, 166, 0.1); - background: rgba(13, 28, 48, 0.62); - color: inherit; - font: inherit; - text-align: left; - cursor: pointer; -} - -.root :global(.scan-opportunity-group-head:hover) { - background: rgba(18, 38, 64, 0.78); -} - -.root :global(.scan-opportunity-group-head:focus-visible) { - outline: 2px solid rgba(34, 211, 238, 0.72); - outline-offset: -2px; -} - -.root :global(.scan-opportunity-city) { - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; -} - -.root :global(.scan-opportunity-city strong) { - color: #eef7ff; - font-size: 18px; - font-weight: 900; -} - -.root :global(.scan-opportunity-models) { - display: flex; - flex-wrap: wrap; - gap: 8px; - min-width: 0; -} - -.root :global(.scan-opportunity-models span) { - display: inline-flex; - gap: 5px; - align-items: baseline; - min-width: 0; - padding: 5px 8px; - border: 1px solid rgba(122, 154, 196, 0.14); - border-radius: 8px; - background: rgba(4, 12, 22, 0.28); - color: #9fb4cf; - font-size: 12px; - font-weight: 800; - line-height: 1.2; - white-space: nowrap; -} - -.root :global(.scan-opportunity-models em) { - color: #6f88aa; - font-style: normal; - font-weight: 800; -} - -.root :global(.scan-opportunity-models b) { - overflow: hidden; - color: #dceaff; - font-size: 13px; - font-weight: 900; - text-overflow: ellipsis; -} - -.root :global(.scan-opportunity-phase) { - display: flex; - gap: 10px; - align-items: center; - color: #9fb4cf; - font-size: 13px; - white-space: nowrap; -} - -.root :global(.scan-opportunity-phase > span) { - color: #eef7ff; - font-weight: 800; -} - -.root :global(.scan-opportunity-phase em) { - color: #9fb4cf; - font-style: normal; -} - -.root :global(.scan-opportunity-items) { - display: grid; - gap: 6px; - padding: 10px 12px 12px; -} - -.root :global(.scan-opportunity-item) { - display: grid; - grid-template-columns: - 16px minmax(96px, 0.7fr) minmax(82px, 0.45fr) - minmax(114px, 0.52fr) minmax(96px, 0.46fr) - minmax(92px, 0.42fr) max-content max-content; - gap: 10px; - align-items: center; - width: 100%; - min-height: 52px; - padding: 8px 14px; - border: 1px solid rgba(90, 123, 166, 0.1); - border-radius: 12px; - background: rgba(4, 12, 22, 0.48); - color: #b8c9e1; - text-align: left; - cursor: pointer; - transition: - background 0.18s ease, - border-color 0.18s ease, - transform 0.18s ease; -} - -.root :global(.scan-opportunity-item:last-child) { - border-bottom: 1px solid rgba(90, 123, 166, 0.1); -} - -.root :global(.scan-opportunity-item:hover) { - background: rgba(22, 48, 78, 0.5); - border-color: rgba(109, 145, 190, 0.2); -} - -.root :global(.scan-opportunity-item.selected) { - background: linear-gradient(180deg, rgba(5, 43, 42, 0.72), rgba(6, 30, 34, 0.72)); - border-color: rgba(23, 217, 139, 0.42); - box-shadow: inset 3px 0 0 rgba(23, 217, 139, 0.82); -} - -.root :global(.scan-opportunity-item.ai-approve) { - border-color: rgba(94, 234, 212, 0.28); -} - -.root :global(.scan-opportunity-item.ai-veto) { - opacity: 0.72; -} - -.root :global(.scan-opportunity-item.ai-veto .scan-opportunity-action) { - color: #9FB2C7; -} - -.root :global(.scan-opportunity-branch) { - position: relative; - align-self: stretch; - width: 16px; -} - -.root :global(.scan-opportunity-branch::before) { - position: absolute; - top: -18px; - bottom: 50%; - left: 7px; - width: 1px; - background: rgba(122, 154, 196, 0.28); - content: ""; -} - -.root :global(.scan-opportunity-branch i) { - position: absolute; - top: 50%; - left: 7px; - width: 8px; - height: 1px; - background: rgba(122, 154, 196, 0.32); -} - -.root :global(.scan-opportunity-trade) { - display: flex; - min-width: 0; - align-items: center; -} - -.root :global(.scan-opportunity-action) { - overflow: hidden; - font-size: 15px; - font-weight: 900; - line-height: 1.2; - text-overflow: ellipsis; - white-space: nowrap; -} - -.root :global(.scan-opportunity-action.buy) { - color: #22e895; -} - -.root :global(.scan-opportunity-action.sell) { - color: #ff7474; -} - -.root :global(.scan-opportunity-stat) { - display: inline-flex; - align-items: baseline; - gap: 6px; - min-width: 76px; - max-width: 132px; - padding: 6px 8px; - border: 1px solid rgba(90, 123, 166, 0.12); - border-radius: 9px; - background: rgba(13, 28, 48, 0.46); - color: #d0ddf0; -} - -.root :global(.scan-opportunity-stat small) { - overflow: hidden; - max-width: 100%; - color: #718bac; - font-size: 9px; - font-weight: 900; - line-height: 1.1; - text-overflow: ellipsis; - text-transform: uppercase; - white-space: nowrap; -} - -.root :global(.scan-opportunity-stat b) { - overflow: hidden; - max-width: 100%; - color: #dceaff; - font-size: 13px; - font-weight: 900; - line-height: 1.25; - text-overflow: ellipsis; - white-space: nowrap; -} - -.root :global(.scan-opportunity-stat.edge) { - justify-self: end; - min-width: 92px; - justify-content: flex-end; -} - -.root :global(.scan-opportunity-stat.threshold) { - min-width: 82px; -} - -.root :global(.scan-opportunity-stat b.positive) { - color: #1de28f; -} - -.root :global(.scan-opportunity-stat b.negative) { - color: #ff6868; -} - -.root :global(.scan-opportunity-ai) { - grid-column: 2 / -1; - display: grid; - gap: 3px; - min-width: 0; - padding: 8px 10px; - border-radius: 10px; - border: 1px solid rgba(90, 123, 166, 0.12); - background: rgba(5, 14, 25, 0.5); -} - -.root :global(.scan-opportunity-ai b) { - font-size: 11px; - font-weight: 900; -} - -.root :global(.scan-opportunity-ai small) { - overflow: hidden; - color: #8aa1bf; - font-size: 12px; - line-height: 1.45; - text-overflow: ellipsis; - white-space: nowrap; -} - -.root :global(.scan-opportunity-ai.approve b) { - color: #7dfbe7; -} - -.root :global(.scan-opportunity-ai.downgrade b) { - color: #ffc765; -} - -.root :global(.scan-opportunity-ai.veto b) { - color: #ff8585; -} - -.root :global(.scan-opportunity-ai.neutral b) { - color: #9fc5ff; -} - -.root :global(.scan-opportunity-strength) { - display: inline-flex; - justify-self: end; - align-items: center; - justify-content: center; - min-width: 66px; - min-height: 32px; - padding: 0 10px; - border-radius: 999px; - border: 1px solid rgba(90, 123, 166, 0.16); - background: rgba(13, 28, 48, 0.6); - color: #91a7c4; - font-size: 12px; - font-weight: 900; - white-space: nowrap; -} - -.root :global(.scan-opportunity-strength.strong) { - color: #ff8585; - border-color: rgba(248, 113, 113, 0.32); - background: rgba(248, 113, 113, 0.1); -} - -.root :global(.scan-opportunity-strength.medium) { - color: #ffd166; - border-color: rgba(245, 158, 11, 0.3); - background: rgba(245, 158, 11, 0.1); -} - -.root :global(.scan-opportunity-strength.watch) { - color: #7aa8ff; - border-color: rgba(96, 165, 250, 0.28); - background: rgba(59, 130, 246, 0.1); -} - -.root :global(.scan-opportunity-strength.approve) { - color: #71f7c8; - border-color: rgba(45, 212, 191, 0.34); - background: rgba(20, 184, 166, 0.12); -} - -.root :global(.scan-opportunity-strength.downgrade), -.root :global(.scan-opportunity-strength.watchlist) { - color: #ffd166; - border-color: rgba(245, 158, 11, 0.32); - background: rgba(245, 158, 11, 0.12); -} - -.root :global(.scan-opportunity-strength.veto) { - color: #ff8585; - border-color: rgba(248, 113, 113, 0.34); - background: rgba(248, 113, 113, 0.12); -} - -.root :global(.scan-opportunity-expand) { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 7px; - min-height: 34px; - padding: 0 10px; - border: 1px solid rgba(65, 118, 216, 0.34); - border-radius: 9px; - background: rgba(10, 30, 58, 0.8); - color: #7fb4ff; - font-size: 12px; - font-weight: 900; - white-space: nowrap; - cursor: pointer; -} - -.root :global(.scan-opportunity-expand:hover) { - border-color: rgba(96, 165, 250, 0.5); - background: rgba(20, 47, 86, 0.86); -} - -.root :global(.scan-opportunity-expand:focus-visible) { - outline: 2px solid rgba(34, 211, 238, 0.72); - outline-offset: 2px; -} - -.root :global(.scan-v4-analysis) { - grid-column: 2 / -1; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - padding: 12px; - border: 1px solid rgba(69, 116, 178, 0.18); - border-radius: 12px; - background: - linear-gradient(180deg, rgba(9, 23, 38, 0.9), rgba(6, 16, 28, 0.92)), - radial-gradient(circle at 0 0, rgba(34, 211, 238, 0.1), transparent 30%); -} - -.root :global(.scan-v4-analysis section) { - min-width: 0; - padding: 10px; - border: 1px solid rgba(90, 123, 166, 0.12); - border-radius: 10px; - background: rgba(6, 16, 28, 0.52); -} - -.root :global(.scan-v4-analysis section:first-child), -.root :global(.scan-v4-analysis .scan-v4-evidence) { - grid-column: 1 / -1; -} - -.root :global(.scan-v4-analysis strong) { - display: block; - color: #6FB7FF; - font-size: 12px; - font-weight: 900; - letter-spacing: 0.02em; - text-transform: none; -} - -.root :global(.scan-v4-analysis p) { - margin: 7px 0 0; - color: #c2d2e7; - font-size: 13px; - font-weight: 700; - line-height: 1.5; -} - -.root :global(.scan-v4-analysis ul) { - display: grid; - gap: 7px; - margin: 8px 0 0; - padding-left: 17px; - color: #aabdd6; - font-size: 12px; - font-weight: 700; - line-height: 1.45; -} - -.root :global(.scan-v4-evidence > div) { - display: flex; - flex-wrap: wrap; - gap: 7px; - margin-top: 8px; -} - -.root :global(.scan-v4-evidence > div > span), -.root :global(.scan-v4-model-sources span) { - display: inline-flex; - align-items: baseline; - gap: 5px; - min-height: 28px; - padding: 5px 8px; - border: 1px solid rgba(122, 154, 196, 0.14); - border-radius: 8px; - background: rgba(13, 28, 48, 0.58); - color: #d4e4f8; - font-size: 12px; - font-weight: 800; -} - -.root :global(.scan-v4-model-sources em) { - color: #839abd; - font-style: normal; - font-weight: 900; -} - -.root :global(.scan-v4-model-sources b) { - color: #eef7ff; - font-size: 12px; - font-weight: 900; -} - -.root :global(.scan-opportunity-group) { - overflow: visible; - border-radius: 14px; -} - -.root :global(.scan-opportunity-items) { - gap: 10px; - padding: 10px 12px 14px; -} - -.root :global(.scan-opportunity-item) { - display: flex; - flex-direction: column; - gap: 10px; - align-items: stretch; - min-height: 0; - padding: 0; - overflow: visible; - border-color: rgba(89, 128, 178, 0.16); - border-radius: 14px; - background: rgba(6, 18, 32, 0.72); -} - -.root :global(.scan-opportunity-item.expanded) { - border-color: rgba(23, 217, 139, 0.42); - background: - linear-gradient(180deg, rgba(8, 29, 43, 0.94), rgba(5, 17, 31, 0.96)), - radial-gradient(circle at 12% 0%, rgba(20, 184, 166, 0.12), transparent 36%); - box-shadow: - inset 0 1px 0 rgba(125, 251, 231, 0.09), - 0 18px 42px rgba(0, 0, 0, 0.2); -} - -.root :global(.scan-opportunity-summary-row) { - display: grid; - grid-template-columns: - 4px minmax(128px, 180px) minmax(360px, 1fr) - max-content max-content; - gap: 10px; - align-items: stretch; - padding: 12px 14px; -} - -.root :global(.scan-opportunity-branch) { - align-self: stretch; - width: 4px; - border-radius: 999px; - background: linear-gradient(180deg, rgba(23, 217, 139, 0.92), rgba(34, 211, 238, 0.28)); -} - -.root :global(.scan-opportunity-branch::before), -.root :global(.scan-opportunity-branch i) { - display: none; -} - -.root :global(.scan-opportunity-metrics) { - display: grid; - grid-template-columns: repeat(4, minmax(96px, 1fr)); - gap: 8px; - min-width: 0; -} - -.root :global(.scan-opportunity-stat) { - display: grid; - align-items: start; - gap: 3px; - min-width: 0; - max-width: none; - width: 100%; - padding: 8px 10px; - border-color: rgba(90, 123, 166, 0.14); - background: rgba(8, 25, 43, 0.62); -} - -.root :global(.scan-opportunity-stat.edge) { - justify-self: stretch; - justify-content: stretch; - justify-items: start; - min-width: 0; -} - -.root :global(.scan-opportunity-trade) { - flex-direction: column; - align-items: flex-start; - justify-content: center; - gap: 2px; -} - -.root :global(.scan-opportunity-action) { - white-space: normal; -} - -.root :global(.scan-opportunity-ai) { - display: grid; - grid-column: auto; - gap: 4px; - margin: 0 14px; - padding: 11px 13px; - border-color: rgba(64, 104, 168, 0.16); - background: rgba(5, 15, 27, 0.64); -} - -.root :global(.scan-opportunity-ai small) { - overflow: visible; - color: #a9bdd8; - white-space: normal; - text-overflow: clip; -} - .root :global(.scan-v4-analysis) { grid-column: auto; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -2483,375 +1613,6 @@ padding: 16px 18px 20px; } -.root :global(.scan-forecast-city-card) { - overflow: visible; - border-color: rgba(159, 178, 199, 0.14); - border-radius: 18px; - background: - linear-gradient(180deg, rgba(17, 26, 46, 0.96), rgba(11, 18, 32, 0.98)), - radial-gradient(circle at 0 0, rgba(77, 163, 255, 0.08), transparent 26%); -} - -.root :global(.scan-forecast-city-card.selected), -.root :global(.scan-forecast-city-card:has(.scan-forecast-row.selected)) { - border-color: rgba(77, 163, 255, 0.42); - box-shadow: - inset 0 0 0 1px rgba(77, 163, 255, 0.14), - 0 20px 54px rgba(0, 0, 0, 0.2); -} - -.root :global(.scan-forecast-city-head) { - grid-template-columns: minmax(0, 1fr) minmax(250px, 340px); - padding: 18px 20px; - border-bottom-color: rgba(159, 178, 199, 0.1); - background: rgba(22, 33, 58, 0.56); -} - -.root :global(.scan-forecast-city-title) { - display: grid; - gap: 8px; - min-width: 0; -} - -.root :global(.scan-forecast-kicker) { - color: #4DA3FF; - font-size: 11px; - font-weight: 950; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.root :global(.scan-forecast-city-title strong) { - color: #E6EDF3; - font-size: 24px; - font-weight: 950; - letter-spacing: 0; -} - -.root :global(.scan-forecast-city-chips) { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.root :global(.scan-forecast-city-chips span) { - min-height: 28px; - padding: 6px 9px; - border: 1px solid rgba(159, 178, 199, 0.14); - border-radius: 9px; - background: rgba(22, 33, 58, 0.56); - color: #9FB2C7; - font-size: 12px; - font-weight: 850; - line-height: 1.2; -} - -.root :global(.scan-forecast-city-read) { - display: grid; - justify-items: end; - gap: 5px; - min-width: 0; - color: #9FB2C7; - text-align: right; -} - -.root :global(.scan-forecast-city-read small) { - color: #6B7A90; - font-size: 11px; - font-weight: 950; - text-transform: uppercase; -} - -.root :global(.scan-forecast-city-read > b:not(.scan-phase-badge)) { - color: #E6EDF3; - font-size: 30px; - font-weight: 950; - line-height: 1; -} - -.root :global(.scan-forecast-city-read span) { - color: #9FB2C7; - font-size: 12px; - font-weight: 850; -} - -.root :global(.scan-forecast-row) { - gap: 0; - overflow: hidden; - border-color: rgba(159, 178, 199, 0.12); - border-radius: 16px; - background: rgba(17, 26, 46, 0.76); - transition: - background 0.18s ease, - border-color 0.18s ease, - box-shadow 0.18s ease; -} - -.root :global(.scan-forecast-row:hover) { - border-color: rgba(77, 163, 255, 0.28); - background: rgba(22, 33, 58, 0.88); -} - -.root :global(.scan-forecast-row.selected), -.root :global(.scan-forecast-row.expanded) { - border-color: rgba(77, 163, 255, 0.42); - background: - linear-gradient(180deg, rgba(13, 37, 66, 0.76), rgba(11, 18, 32, 0.9)), - radial-gradient(circle at 0 0, rgba(77, 163, 255, 0.12), transparent 32%); - box-shadow: inset 4px 0 0 rgba(77, 163, 255, 0.78); -} - -.root :global(.scan-forecast-row-main) { - display: grid; - grid-template-columns: minmax(260px, 1.05fr) minmax(360px, 1fr) max-content max-content; - gap: 12px; - align-items: stretch; - padding: 14px 16px; -} - -.root :global(.scan-forecast-bucket) { - display: grid; - align-content: center; - gap: 4px; - min-width: 0; -} - -.root :global(.scan-forecast-bucket span), -.root :global(.scan-forecast-bucket small), -.root :global(.scan-forecast-signals small), -.root :global(.scan-ai-temperature-line small) { - color: #6B7A90; - font-size: 11px; - font-weight: 900; - letter-spacing: 0.02em; -} - -.root :global(.scan-forecast-bucket strong) { - color: #E6EDF3; - font-size: 18px; - font-weight: 950; - line-height: 1.22; -} - -.root :global(.scan-forecast-bucket small) { - color: #9FB2C7; - line-height: 1.45; -} - -.root :global(.scan-forecast-signals) { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 8px; - min-width: 0; -} - -.root :global(.scan-forecast-signals span) { - display: grid; - align-content: center; - gap: 4px; - min-width: 0; - padding: 9px 11px; - border: 1px solid rgba(159, 178, 199, 0.12); - border-radius: 11px; - background: rgba(22, 33, 58, 0.68); -} - -.root :global(.scan-forecast-signals b) { - display: -webkit-box; - overflow: hidden; - color: #E6EDF3; - font-size: 12px; - font-weight: 900; - line-height: 1.25; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - text-overflow: ellipsis; - white-space: normal; -} - -.root :global(.scan-forecast-fit) { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 110px; - min-height: 40px; - padding: 0 13px; - border: 1px solid rgba(96, 165, 250, 0.28); - border-radius: 999px; - background: rgba(59, 130, 246, 0.1); - color: #6FB7FF; - font-size: 12px; - font-weight: 950; - white-space: nowrap; -} - -.root :global(.scan-forecast-fit.approve), -.root :global(.scan-ai-forecast-pill.approve) { - border-color: rgba(34, 197, 94, 0.34); - background: rgba(34, 197, 94, 0.12); - color: #86EFAC; -} - -.root :global(.scan-forecast-fit.downgrade), -.root :global(.scan-forecast-fit.watchlist), -.root :global(.scan-ai-forecast-pill.downgrade), -.root :global(.scan-ai-forecast-pill.watchlist) { - border-color: rgba(245, 158, 11, 0.34); - background: rgba(245, 158, 11, 0.12); - color: #F59E0B; -} - -.root :global(.scan-forecast-fit.veto), -.root :global(.scan-ai-forecast-pill.veto) { - border-color: rgba(248, 113, 113, 0.34); - background: rgba(248, 113, 113, 0.12); - color: #EF4444; -} - -.root :global(.scan-forecast-ai-line) { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - gap: 10px; - align-items: baseline; - padding: 0 16px 14px; - color: #9FB2C7; -} - -.root :global(.scan-forecast-ai-line b) { - color: #6FB7FF; - font-size: 12px; - font-weight: 950; -} - -.root :global(.scan-forecast-ai-line small) { - min-width: 0; - color: #9FB2C7; - font-size: 13px; - font-weight: 750; - line-height: 1.5; -} - -.root :global(.scan-ai-analysis) { - display: grid; - gap: 14px; - margin: 0 16px 16px; - padding: 16px; - border-top: 1px solid rgba(159, 178, 199, 0.12); - background: rgba(11, 18, 32, 0.38); -} - -.root :global(.scan-ai-analysis-head) { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 18px; -} - -.root :global(.scan-ai-analysis-head > div) { - display: grid; - gap: 7px; - min-width: 0; -} - -.root :global(.scan-ai-analysis-head strong), -.root :global(.scan-ai-brief-grid strong) { - color: #6FB7FF; - font-size: 12px; - font-weight: 950; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.root :global(.scan-ai-analysis-head p) { - margin: 0; - color: #E6EDF3; - font-size: 20px; - font-weight: 850; - line-height: 1.3; -} - -.root :global(.scan-ai-analysis-head small) { - max-width: 880px; - color: #9FB2C7; - font-size: 14px; - font-weight: 760; - line-height: 1.55; -} - -.root :global(.scan-ai-forecast-pill) { - display: inline-flex; - flex: 0 0 auto; - align-items: center; - justify-content: center; - min-height: 32px; - padding: 0 13px; - border: 1px solid rgba(96, 165, 250, 0.28); - border-radius: 999px; - background: rgba(59, 130, 246, 0.1); - color: #6FB7FF; - font-size: 12px; - font-weight: 950; - white-space: nowrap; -} - -.root :global(.scan-ai-temperature-line) { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; - padding: 13px 0; - border-top: 1px solid rgba(159, 178, 199, 0.1); - border-bottom: 1px solid rgba(159, 178, 199, 0.1); -} - -.root :global(.scan-ai-temperature-line span) { - display: grid; - gap: 4px; - min-width: 0; -} - -.root :global(.scan-ai-temperature-line b) { - overflow: hidden; - color: #E6EDF3; - font-size: 17px; - font-weight: 950; - text-overflow: ellipsis; - white-space: nowrap; -} - -.root :global(.scan-ai-evidence-line) { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; -} - -.root :global(.scan-ai-evidence-line span) { - display: grid; - gap: 4px; - min-width: 0; - padding: 10px 12px; - border: 1px solid rgba(159, 178, 199, 0.1); - border-radius: 10px; - background: rgba(22, 33, 58, 0.54); -} - -.root :global(.scan-ai-evidence-line small) { - color: #6B7A90; - font-size: 11px; - font-weight: 900; -} - -.root :global(.scan-ai-evidence-line b) { - display: -webkit-box; - overflow: hidden; - color: #E6EDF3; - font-size: 13px; - font-weight: 900; - line-height: 1.3; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - text-overflow: ellipsis; - white-space: normal; -} .root :global(.scan-ai-workspace) { flex: 1; @@ -2913,1136 +1674,6 @@ text-align: right; } -.root :global(.scan-opportunity-overview) { - flex: 1; - min-height: 0; - overflow-y: auto; - overscroll-behavior: contain; - border: 1px solid rgba(77, 163, 255, 0.16); - border-radius: 18px; - background: - radial-gradient(circle at 12% 0%, rgba(77, 163, 255, 0.16), transparent 32%), - #0b1220; - padding: 18px; -} - -.root :global(.scan-opportunity-overview.loading), -.root :global(.scan-opportunity-overview.empty) { - display: grid; - place-items: center; - align-content: center; - gap: 12px; - text-align: center; - color: #9fb2c7; -} - -.root :global(.scan-opportunity-overview.empty strong) { - color: #e6edf3; - font-size: 22px; -} - -.root :global(.scan-opportunity-overview.empty button) { - border: 1px solid rgba(77, 163, 255, 0.38); - border-radius: 999px; - background: rgba(77, 163, 255, 0.12); - color: #9ecbff; - cursor: pointer; - font-weight: 900; - padding: 10px 16px; -} - -.root :global(.scan-opportunity-hero) { - display: grid; - grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr); - gap: 18px; - align-items: stretch; - margin-bottom: 18px; -} - -.root :global(.scan-opportunity-hero > div:first-child) { - border: 1px solid rgba(77, 163, 255, 0.2); - border-radius: 18px; - background: rgba(17, 26, 46, 0.78); - padding: 18px; -} - -.root :global(.scan-opportunity-hero span) { - color: #4da3ff; - font-size: 12px; - font-weight: 900; -} - -.root :global(.scan-opportunity-hero strong) { - display: block; - margin-top: 4px; - color: #e6edf3; - font-size: 26px; - line-height: 1.15; -} - -.root :global(.scan-opportunity-hero p) { - max-width: 680px; - margin: 9px 0 0; - color: #9fb2c7; - font-size: 13px; - line-height: 1.6; -} - -.root :global(.scan-opportunity-summary) { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; -} - -.root :global(.scan-opportunity-summary span) { - display: grid; - gap: 5px; - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 14px; - background: rgba(13, 17, 23, 0.52); - color: #9fb2c7; - padding: 12px; -} - -.root :global(.scan-opportunity-summary b) { - color: #e6edf3; - font-size: 18px; -} - -.root :global(.scan-opportunity-lanes) { - display: grid; - gap: 16px; -} - -.root :global(.scan-opportunity-lane) { - border: 1px solid rgba(77, 163, 255, 0.14); - border-radius: 18px; - background: rgba(13, 17, 23, 0.38); - padding: 14px; -} - -.root :global(.scan-opportunity-lane-head) { - display: flex; - justify-content: space-between; - gap: 16px; - margin-bottom: 12px; -} - -.root :global(.scan-opportunity-lane-head strong) { - color: #e6edf3; - font-size: 17px; -} - -.root :global(.scan-opportunity-lane-head p) { - margin: 3px 0 0; - color: #9fb2c7; - font-size: 12px; -} - -.root :global(.scan-opportunity-lane-head > span) { - display: grid; - place-items: center; - min-width: 32px; - height: 32px; - border-radius: 999px; - background: rgba(77, 163, 255, 0.12); - color: #9ecbff; - font-weight: 900; -} - -.root :global(.scan-opportunity-card-grid) { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; -} - -.root :global(.scan-opportunity-decision-card) { - display: grid; - gap: 12px; - border: 1px solid rgba(255, 255, 255, 0.07); - border-radius: 16px; - background: rgba(17, 26, 46, 0.78); - color: inherit; - cursor: pointer; - padding: 14px; - transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease; -} - -.root :global(.scan-opportunity-decision-card:hover), -.root :global(.scan-opportunity-decision-card.selected) { - border-color: rgba(77, 163, 255, 0.42); - background: rgba(20, 34, 58, 0.9); - transform: translateY(-1px); -} - -.root :global(.scan-opportunity-decision-card.trade) { - border-color: rgba(34, 197, 94, 0.34); -} - -.root :global(.scan-opportunity-decision-card.wait) { - border-color: rgba(245, 158, 11, 0.34); -} - -.root :global(.scan-opportunity-decision-card.risk), -.root :global(.scan-opportunity-decision-card.avoid) { - border-color: rgba(239, 68, 68, 0.32); -} - -.root :global(.scan-opportunity-decision-head), -.root :global(.scan-opportunity-decision-primary), -.root :global(.scan-opportunity-decision-foot) { - display: flex; - gap: 10px; - justify-content: space-between; -} - -.root :global(.scan-opportunity-decision-head span), -.root :global(.scan-opportunity-decision-primary span), -.root :global(.scan-opportunity-decision-foot small) { - color: #9fb2c7; - font-size: 11px; - font-weight: 800; -} - -.root :global(.scan-opportunity-decision-head strong) { - display: block; - margin-top: 3px; - color: #e6edf3; - font-size: 18px; -} - -.root :global(.scan-opportunity-decision-head > b) { - align-self: start; - border-radius: 999px; - background: rgba(77, 163, 255, 0.14); - color: #9ecbff; - font-size: 12px; - padding: 6px 10px; - white-space: nowrap; -} - -.root :global(.scan-opportunity-decision-card.trade .scan-opportunity-decision-head > b) { - background: rgba(34, 197, 94, 0.14); - color: #86efac; -} - -.root :global(.scan-opportunity-decision-card.wait .scan-opportunity-decision-head > b) { - background: rgba(245, 158, 11, 0.14); - color: #facc15; -} - -.root :global(.scan-opportunity-decision-card.risk .scan-opportunity-decision-head > b), -.root :global(.scan-opportunity-decision-card.avoid .scan-opportunity-decision-head > b) { - background: rgba(239, 68, 68, 0.14); - color: #fca5a5; -} - -.root :global(.scan-opportunity-decision-primary span) { - display: grid; - gap: 4px; - flex: 1; - border-radius: 12px; - background: rgba(13, 17, 23, 0.52); - padding: 9px; -} - -.root :global(.scan-opportunity-decision-primary b) { - color: #e6edf3; - font-size: 14px; -} - -.root :global(.scan-opportunity-decision-card p) { - min-height: 38px; - margin: 0; - color: #b6c7db; - font-size: 12px; - line-height: 1.55; -} - -.root :global(.scan-opportunity-decision-foot) { - flex-wrap: wrap; - justify-content: flex-start; -} - -.root :global(.scan-opportunity-decision-foot small) { - border-radius: 999px; - background: rgba(77, 163, 255, 0.08); - padding: 5px 8px; -} - -.root :global(.scan-opportunity-decision-card button) { - justify-self: start; - border: 1px solid rgba(77, 163, 255, 0.34); - border-radius: 10px; - background: rgba(77, 163, 255, 0.12); - color: #9ecbff; - cursor: pointer; - font-size: 12px; - font-weight: 900; - padding: 8px 10px; -} - -.root :global(.scan-ai-city-stack) { - display: grid; - gap: 18px; -} - -.root :global(.scan-ai-city-card) { - overflow: hidden; - border: 1px solid rgba(77, 163, 255, 0.35); - border-radius: 18px; - background: #111a2e; - box-shadow: 0 18px 48px rgba(0, 0, 0, 0.24); - opacity: 1; - transform: translateX(0); - transition: - opacity 0.24s ease, - transform 0.24s ease, - border-color 0.18s ease, - background 0.18s ease; -} - -.root :global(.scan-ai-city-card.removing) { - pointer-events: none; - opacity: 0; - transform: translateX(44px); -} - -.root :global(.scan-ai-city-hero) { - display: flex; - justify-content: space-between; - gap: 18px; - padding: 22px; - background: - linear-gradient(135deg, rgba(77, 163, 255, 0.11), rgba(34, 197, 94, 0.03)), - #111a2e; - border-bottom: 1px solid rgba(159, 178, 199, 0.12); -} - -.root :global(.scan-ai-city-kicker) { - color: #4da3ff; - font-size: 12px; - font-weight: 800; -} - -.root :global(.scan-ai-city-hero h3) { - margin: 8px 0 12px; - color: #e6edf3; - font-size: 28px; - line-height: 1.1; -} - -.root :global(.scan-ai-city-mobile-priority) { - display: none; -} - -.root :global(.scan-ai-city-pills) { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.root :global(.scan-ai-city-pills span) { - border: 1px solid rgba(159, 178, 199, 0.14); - border-radius: 10px; - background: rgba(11, 18, 32, 0.58); - color: #c9d7e8; - font-size: 12px; - font-weight: 800; - padding: 7px 10px; -} - -.root :global(.scan-ai-city-status-tags) { - display: flex; - flex-wrap: wrap; - gap: 7px; - margin: -2px 0 10px; -} - -.root :global(.scan-ai-city-status-tag) { - border: 1px solid rgba(159, 178, 199, 0.16); - border-radius: 999px; - background: rgba(15, 23, 42, 0.62); - color: #cbd5e1; - font-size: 11px; - font-weight: 900; - letter-spacing: 0.01em; - padding: 5px 9px; -} - -.root :global(.scan-ai-city-status-tag.green) { - border-color: rgba(34, 197, 94, 0.34); - background: rgba(34, 197, 94, 0.12); - color: #86efac; -} - -.root :global(.scan-ai-city-status-tag.blue) { - border-color: rgba(77, 163, 255, 0.36); - background: rgba(77, 163, 255, 0.13); - color: #9ecbff; -} - -.root :global(.scan-ai-city-status-tag.amber) { - border-color: rgba(245, 158, 11, 0.38); - background: rgba(245, 158, 11, 0.13); - color: #fcd34d; -} - -.root :global(.scan-ai-city-status-tag.red) { - border-color: rgba(248, 113, 113, 0.4); - background: rgba(239, 68, 68, 0.13); - color: #fca5a5; -} - -.root :global(.scan-ai-city-status-tag.muted) { - color: #94a3b8; -} - -.root :global(.scan-ai-city-freshness) { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 6px; - max-width: 620px; - margin-top: 10px; - color: #9fb2c7; - font-size: 11px; - font-weight: 760; -} - -.root :global(.scan-ai-city-freshness strong) { - grid-column: 1 / -1; - color: #d8e7f8; - font-size: 11px; - font-weight: 900; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.root :global(.scan-ai-city-freshness span) { - display: inline-flex; - align-items: center; - gap: 2px; - border-radius: 8px; - background: rgba(148, 163, 184, 0.08); - padding: 4px 7px; -} - -.root :global(.scan-ai-city-freshness b) { - color: #d6e2f0; - font-style: normal; - font-weight: 900; -} - -.root :global(.scan-ai-city-freshness em) { - color: #9fb2c7; - font-style: normal; -} - -.root :global(.scan-ai-city-freshness span.fresh em), -.root :global(.scan-ai-city-freshness span.green em) { - color: #86efac; -} - -.root :global(.scan-ai-city-freshness span.loading em), -.root :global(.scan-ai-city-freshness span.blue em) { - color: #9ecbff; -} - -.root :global(.scan-ai-city-freshness span.stale em), -.root :global(.scan-ai-city-freshness span.amber em) { - color: #fcd34d; -} - -.root :global(.scan-ai-city-freshness span.red em) { - color: #fca5a5; -} - -.root :global(.scan-ai-city-hero-side) { - min-width: 180px; - display: grid; - justify-items: end; - align-content: start; - gap: 6px; - text-align: right; -} - -.root :global(.scan-ai-city-hero-side > span) { - color: #9fb2c7; - font-size: 12px; - font-weight: 800; -} - -.root :global(.scan-ai-city-hero-side > strong) { - color: #e6edf3; - font-size: 30px; - line-height: 1; -} - -.root :global(.scan-ai-city-actions) { - display: inline-flex; - justify-content: flex-end; - gap: 8px; - margin-top: 4px; -} - -.root :global(.scan-ai-city-icon-button), -.root :global(.scan-ai-city-collapse), -.root :global(.scan-ai-city-price-button) { - min-height: 36px; - display: inline-flex; - align-items: center; - gap: 7px; - border: 1px solid rgba(77, 163, 255, 0.32); - border-radius: 10px; - background: rgba(77, 163, 255, 0.1); - color: #9ecbff; - font-size: 12px; - font-weight: 800; - cursor: pointer; - transition: background 0.18s ease, border-color 0.18s ease; -} - -.root :global(.scan-ai-city-icon-button) { - width: 36px; - justify-content: center; - padding: 0; -} - -.root :global(.scan-ai-city-icon-button.danger) { - border-color: rgba(239, 68, 68, 0.34); - background: rgba(239, 68, 68, 0.1); - color: #fca5a5; -} - -.root :global(.scan-ai-city-icon-button.danger:hover) { - border-color: rgba(239, 68, 68, 0.52); - background: rgba(239, 68, 68, 0.14); - color: #ff9aa4; -} - -.root :global(.scan-ai-city-icon-button:disabled) { - cursor: wait; - opacity: 0.68; -} - -.root :global(.scan-ai-city-icon-button .spin) { - animation: spin 1s linear infinite; -} - -.root :global(.scan-ai-city-collapse) { - padding: 8px 10px; -} - -.root :global(.scan-ai-city-card.collapsed .scan-ai-city-collapse svg) { - transform: rotate(-90deg); -} - -.root :global(.scan-mobile-decision-card) { - display: grid; - gap: 12px; - padding-bottom: 14px; -} - -.root :global(.scan-mobile-decision-head) { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - padding: 16px 16px 0; -} - -.root :global(.scan-mobile-decision-head h3) { - margin: 6px 0 0; - color: #e6edf3; - font-size: 24px; - line-height: 1.08; -} - -.root :global(.scan-mobile-decision-metrics) { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; - padding: 0 14px; -} - -.root :global(.scan-mobile-decision-metrics span) { - display: grid; - gap: 4px; - border: 1px solid rgba(77, 163, 255, 0.18); - border-radius: 12px; - background: rgba(77, 163, 255, 0.09); - padding: 9px; -} - -.root :global(.scan-mobile-decision-metrics small) { - color: #9fb2c7; - font-size: 10px; - font-weight: 900; -} - -.root :global(.scan-mobile-decision-metrics b) { - color: #f3f8ff; - font-size: 13px; - line-height: 1.15; -} - -.root :global(.scan-mobile-decision-reason) { - margin: 0 14px; - border: 1px solid rgba(77, 163, 255, 0.24); - border-radius: 13px; - background: rgba(77, 163, 255, 0.1); - color: #d8e7f8; - font-size: 14px; - font-weight: 900; - line-height: 1.45; - padding: 11px 12px; -} - -.root :global(.scan-mobile-decision-card .scan-ai-city-status-tags), -.root :global(.scan-mobile-decision-card .scan-ai-city-freshness), -.root :global(.scan-mobile-decision-card .scan-ai-market-mobile-line), -.root :global(.scan-mobile-decision-folds) { - margin-right: 14px; - margin-left: 14px; -} - -.root :global(.scan-mobile-decision-card .scan-ai-city-freshness) { - grid-template-columns: 1fr; - max-width: none; - margin-top: 0; -} - -.root :global(.scan-mobile-decision-card .scan-ai-market-mobile-line) { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-top: 0; -} - -.root :global(.scan-mobile-decision-card .scan-ai-market-decision) { - display: none; -} - -.root :global(.scan-mobile-decision-folds) { - display: grid; - gap: 10px; -} - -.root :global(.scan-mobile-fold) { - padding: 13px 14px; -} - -.root :global(.scan-mobile-fold summary) { - cursor: pointer; - list-style: none; - margin-bottom: 0; -} - -.root :global(.scan-mobile-fold summary::-webkit-details-marker) { - display: none; -} - -.root :global(.scan-mobile-fold[open] summary) { - margin-bottom: 10px; -} - -.root :global(.scan-mobile-fold .scan-ai-city-section.models) { - border: 0; - background: transparent; - padding: 0; -} - -.root :global(.scan-ai-city-collapse svg) { - transition: transform 0.18s ease; -} - -.root :global(.scan-ai-city-price-button) { - padding: 9px 12px; -} - -.root :global(.scan-ai-city-icon-button:hover), -.root :global(.scan-ai-city-collapse:hover), -.root :global(.scan-ai-city-price-button:hover) { - background: rgba(77, 163, 255, 0.18); - border-color: rgba(111, 183, 255, 0.58); -} - -.root :global(.scan-ai-city-price-button:disabled) { - cursor: wait; - opacity: 0.62; -} - -.root :global(.scan-ai-city-body) { - padding: 18px 18px 36px; - max-height: none; - overflow: visible; - overscroll-behavior: contain; -} - -.root :global(.scan-ai-city-body::-webkit-scrollbar) { - width: 8px; -} - -.root :global(.scan-ai-city-body::-webkit-scrollbar-thumb) { - border-radius: 999px; - background: rgba(77, 163, 255, 0.28); -} - -.root :global(.scan-ai-decision-band) { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 18px; - align-items: center; - border: 1px solid rgba(77, 163, 255, 0.18); - border-radius: 16px; - background: #16213a; - padding: 18px; -} - -.root :global(.scan-ai-decision-band.warm) { - border-color: rgba(239, 68, 68, 0.34); -} - -.root :global(.scan-ai-decision-band.cold) { - border-color: rgba(34, 197, 94, 0.34); -} - -.root :global(.scan-ai-decision-band.watch) { - border-color: rgba(245, 158, 11, 0.38); -} - -.root :global(.scan-ai-decision-main) { - min-width: 0; -} - -.root :global(.scan-ai-decision-band span) { - color: #4da3ff; - font-size: 12px; - font-weight: 900; -} - -.root :global(.scan-ai-decision-band strong) { - display: block; - margin-top: 5px; - color: #e6edf3; - font-size: 24px; - line-height: 1.2; -} - -.root :global(.scan-ai-decision-band p) { - margin: 8px 0 0; - color: #9fb2c7; - line-height: 1.55; -} - -.root :global(.scan-ai-decision-why) { - display: inline-flex; - max-width: 100%; - border: 1px solid rgba(77, 163, 255, 0.24); - border-radius: 12px; - background: rgba(77, 163, 255, 0.1); - color: #d8e7f8 !important; - font-weight: 900; - padding: 9px 11px; -} - -.root :global(.scan-ai-decision-reasons) { - display: grid; - gap: 8px; - margin-top: 14px; -} - -.root :global(.scan-ai-decision-reasons small) { - position: relative; - display: block; - padding-left: 16px; - color: #c9d7e8; - font-size: 12px; - font-weight: 760; - line-height: 1.45; -} - -.root :global(.scan-ai-decision-reasons small::before) { - position: absolute; - top: 0.58em; - left: 0; - width: 6px; - height: 6px; - border-radius: 999px; - background: #4da3ff; - content: ""; -} - -.root :global(.scan-ai-decision-risk) { - margin-top: 12px !important; - color: #fbbf24 !important; - font-size: 12px; - font-weight: 800; -} - -.root :global(.scan-ai-market-mobile-line) { - display: none; -} - -.root :global(.scan-ai-market-decision) { - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - gap: 12px; - align-items: center; - margin-top: 14px; - border: 1px solid rgba(77, 163, 255, 0.18); - border-radius: 14px; - background: rgba(11, 18, 32, 0.44); - padding: 12px; -} - -.root :global(.scan-ai-market-decision.warm) { - border-color: rgba(34, 197, 94, 0.36); -} - -.root :global(.scan-ai-market-decision.cold) { - border-color: rgba(239, 68, 68, 0.36); -} - -.root :global(.scan-ai-market-decision.watch) { - border-color: rgba(245, 158, 11, 0.38); -} - -.root :global(.scan-ai-market-decision span) { - color: #9fb2c7; - font-size: 11px; - font-weight: 900; -} - -.root :global(.scan-ai-market-decision strong) { - margin-top: 3px; - color: #e6edf3; - font-size: 15px; -} - -.root :global(.scan-ai-market-decision p) { - margin-top: 5px; - font-size: 12px; -} - -.root :global(.scan-ai-market-decision-stats) { - display: grid; - grid-template-columns: repeat(3, minmax(82px, 1fr)); - gap: 8px; -} - -.root :global(.scan-ai-market-decision-stats small) { - display: grid; - gap: 3px; - border-radius: 10px; - background: rgba(17, 26, 46, 0.82); - color: #9fb2c7; - font-size: 11px; - font-weight: 800; - padding: 8px; -} - -.root :global(.scan-ai-market-decision-stats b) { - color: #e6edf3; - font-size: 13px; -} - -.root :global(.scan-ai-market-link) { - border: 1px solid rgba(77, 163, 255, 0.34); - border-radius: 10px; - background: rgba(77, 163, 255, 0.12); - color: #9ecbff; - font-size: 12px; - font-weight: 900; - padding: 9px 10px; - text-decoration: none; - white-space: nowrap; -} - -.root :global(.scan-ai-decision-metrics) { - min-width: 360px; - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; -} - -.root :global(.scan-ai-decision-metrics span) { - border: 1px solid rgba(159, 178, 199, 0.14); - border-radius: 12px; - background: rgba(11, 18, 32, 0.5); - color: #9fb2c7; - padding: 12px; -} - -.root :global(.scan-ai-decision-metrics b) { - display: block; - margin-top: 4px; - color: #e6edf3; - font-size: 15px; -} - -.root :global(.scan-ai-city-analysis-grid) { - display: grid; - grid-template-columns: minmax(420px, 1.4fr) minmax(280px, 0.8fr); - gap: 14px; - margin-top: 14px; -} - -.root :global(.scan-ai-city-section) { - border: 1px solid rgba(159, 178, 199, 0.12); - border-radius: 16px; - background: rgba(11, 18, 32, 0.38); - padding: 16px; -} - -.root :global(.scan-ai-city-section-title) { - display: inline-flex; - align-items: center; - gap: 8px; - color: #4da3ff; - font-size: 13px; - font-weight: 900; - margin-bottom: 12px; -} - -.root :global(.scan-ai-city-ai-read) { - overflow: hidden; -} - -.root :global(.scan-ai-city-ai-read summary) { - cursor: pointer; - list-style: none; -} - -.root :global(.scan-ai-city-ai-read summary::-webkit-details-marker) { - display: none; -} - -.root :global(.scan-ai-city-ai-read summary::after) { - width: 18px; - height: 18px; - display: inline-grid; - place-items: center; - border-radius: 999px; - background: rgba(77, 163, 255, 0.12); - color: #9ecbff; - content: "⌄"; - font-size: 12px; - line-height: 1; - transition: transform 0.18s ease; -} - -.root :global(.scan-ai-city-ai-read[open] summary::after) { - transform: rotate(180deg); -} - -.root :global(.scan-ai-city-section-body) { - margin-top: 2px; -} - -.root :global(.scan-ai-city-section p) { - margin: 0 0 10px; - color: #9fb2c7; - font-size: 13px; - line-height: 1.6; -} - -.root :global(.scan-ai-weather-summary) { - color: #e6edf3; - font-weight: 850; -} - -.root :global(.scan-ai-weather-bullets) { - display: grid; - gap: 8px; - margin: 10px 0 0; - padding-left: 18px; - color: #9fb2c7; - font-size: 13px; - font-weight: 700; - line-height: 1.6; -} - -.root :global(.scan-ai-weather-bullets li::marker) { - color: #4da3ff; -} - -.root :global(.scan-ai-raw-metar) { - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid rgba(77, 163, 255, 0.12); - color: #6b7a90; - font-family: - ui-monospace, - SFMono-Regular, - Menlo, - Monaco, - Consolas, - "Liberation Mono", - monospace; - font-size: 12px; -} - -.root :global(.scan-ai-city-chart) { - height: 260px; -} - -.root :global(.scan-ai-city-chart canvas) { - display: block; - width: 100% !important; - height: 100% !important; -} - -.root :global(.scan-ai-city-chart-placeholder) { - display: grid; - height: 100%; - place-items: center; - border: 1px dashed rgba(77, 163, 255, 0.22); - border-radius: 14px; - background: rgba(11, 18, 32, 0.35); - color: #7f96b6; - font-size: 12px; - font-weight: 800; -} - -.root :global(.scan-ai-city-chart-legend) { - display: flex; - gap: 14px; - margin-top: 10px; - color: #9fb2c7; - font-size: 12px; - font-weight: 800; -} - -.root :global(.scan-ai-city-chart-legend span) { - display: inline-flex; - align-items: center; - gap: 7px; -} - -.root :global(.scan-ai-city-chart-legend i) { - width: 18px; - height: 3px; - border-radius: 999px; - background: #4da3ff; -} - -.root :global(.scan-ai-city-chart-legend i.observation) { - width: 8px; - height: 8px; - background: #22c55e; -} - -.root :global(.scan-ai-city-section.models) { - margin-top: 14px; -} - -.root :global(.scan-ai-city-section.models .models-section) { - padding: 0; - border: 0; - background: transparent; -} - -.root :global(.scan-ai-city-section-head) { - display: flex; - justify-content: space-between; - gap: 14px; - align-items: flex-start; - margin-bottom: 12px; -} - -.root :global(.scan-ai-city-section.market) { - margin-top: 14px; -} - -.root :global(.scan-ai-market-buckets) { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(155px, 1fr)); - gap: 10px; -} - -.root :global(.scan-ai-market-bucket) { - display: grid; - gap: 5px; - border: 1px solid rgba(159, 178, 199, 0.12); - border-radius: 12px; - background: rgba(17, 26, 46, 0.9); - padding: 12px; -} - -.root :global(.scan-ai-market-bucket strong) { - color: #e6edf3; - font-size: 15px; -} - -.root :global(.scan-ai-market-bucket span), -.root :global(.scan-ai-city-muted), -.root :global(.scan-ai-city-loading) { - color: #9fb2c7; - font-size: 12px; - font-weight: 700; -} - -.root :global(.scan-ai-city-loading) { - padding: 28px; -} - -.root :global(.scan-ai-brief-grid) { - display: grid; - grid-template-columns: minmax(0, 1.25fr) minmax(260px, 0.75fr); - gap: 28px; -} - -.root :global(.scan-ai-brief-grid ul) { - display: grid; - gap: 8px; - margin: 9px 0 0; - padding: 0; - list-style: none; -} - -.root :global(.scan-ai-brief-grid li) { - position: relative; - padding-left: 16px; - color: #9FB2C7; - font-size: 13px; - font-weight: 760; - line-height: 1.48; -} - -.root :global(.scan-ai-brief-grid li::before) { - position: absolute; - top: 0.65em; - left: 0; - width: 5px; - height: 5px; - border-radius: 999px; - background: #4DA3FF; - box-shadow: 0 0 10px rgba(77, 163, 255, 0.34); - content: ""; -} - -.root :global(.scan-ai-brief-grid section:nth-child(2) li::before) { - background: #F59E0B; - box-shadow: 0 0 10px rgba(245, 158, 11, 0.35); -} - -.root :global(.scan-ai-airport-read) { - display: grid; - gap: 6px; - padding-top: 12px; - border-top: 1px solid rgba(159, 178, 199, 0.1); -} - -.root :global(.scan-ai-airport-read p) { - margin: 0; - color: #9FB2C7; - font-size: 12px; - font-weight: 800; - line-height: 1.5; -} - .root :global(.scan-table-body::-webkit-scrollbar), .root :global(.scan-calendar-view::-webkit-scrollbar), .root :global(.scan-detail-panel::-webkit-scrollbar) { @@ -4103,173 +1734,6 @@ font-size: 13px; } -.root :global(.scan-calendar-view) { - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 14px; - padding-right: 4px; -} - -.root :global(.scan-calendar-group) { - border: 1px solid rgba(90, 123, 166, 0.12); - border-radius: 18px; - background: rgba(8, 17, 30, 0.8); - overflow: hidden; -} - -.root :global(.scan-calendar-group-head) { - display: flex; - justify-content: space-between; - align-items: center; - padding: 14px 18px; - border-bottom: 1px solid rgba(90, 123, 166, 0.1); -} - -.root :global(.scan-calendar-date) { - font-size: 16px; - font-weight: 800; - color: #eef7ff; -} - -.root :global(.scan-calendar-count) { - font-size: 13px; - color: #8fa4c3; -} - -.root :global(.scan-calendar-subtitle) { - margin-top: 4px; - color: #7f96b6; - font-size: 12px; - font-weight: 700; -} - -.root :global(.scan-calendar-grid) { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 12px; - padding: 14px; -} - -.root :global(.scan-calendar-card), -.root :global(.scan-summary-card), -.root :global(.scan-settings-card) { - border: 1px solid rgba(90, 123, 166, 0.12); - border-radius: 16px; - background: linear-gradient(180deg, rgba(10, 20, 35, 0.92), rgba(8, 17, 29, 0.92)); -} - -.root :global(.scan-calendar-card) { - text-align: left; - padding: 14px; - cursor: pointer; -} - -.root :global(.scan-calendar-card.selected) { - box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.42); -} - -.root :global(.scan-calendar-card.peak-active) { - border-color: rgba(23, 217, 139, 0.34); - background: linear-gradient(180deg, rgba(12, 42, 38, 0.9), rgba(8, 20, 30, 0.94)); -} - -.root :global(.scan-calendar-card.peak-next) { - border-color: rgba(94, 234, 212, 0.3); -} - -.root :global(.scan-calendar-card.peak-past) { - opacity: 0.78; -} - -.root :global(.scan-calendar-city) { - font-size: 16px; - font-weight: 800; - color: #eef7ff; -} - -.root :global(.scan-calendar-action) { - margin-top: 8px; - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 10px; - font-size: 15px; - font-weight: 800; - color: #dcecff; -} - -.root :global(.scan-calendar-action span) { - color: #8fa4c3; - font-size: 12px; - font-weight: 800; -} - -.root :global(.scan-calendar-action b) { - color: #eef7ff; - font-size: 16px; - font-weight: 900; -} - -.root :global(.scan-calendar-action.buy) { - color: #21e391; -} - -.root :global(.scan-calendar-action.sell) { - color: #ff6e6e; -} - -.root :global(.scan-calendar-meta) { - margin-top: 10px; - display: flex; - justify-content: space-between; - gap: 12px; - color: #8fa4c3; - font-size: 13px; -} - -.root :global(.scan-calendar-countdown) { - margin-top: 12px; - padding: 10px 12px; - border: 1px solid rgba(94, 234, 212, 0.18); - border-radius: 12px; - background: rgba(20, 184, 166, 0.1); - color: #5fffe8; - font-size: 15px; - font-weight: 900; -} - -.root :global(.scan-calendar-countdown small) { - display: block; - margin-top: 4px; - color: #9fb4d2; - font-size: 12px; - font-weight: 800; -} - -.root :global(.scan-calendar-reason) { - margin: 10px 0 0; - color: #d6e4f5; - font-size: 13px; - font-weight: 760; - line-height: 1.45; -} - -.root :global(.scan-summary-grid) { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 14px; -} - -.root :global(.scan-summary-grid.monitor) { - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); -} - -.root :global(.scan-summary-card), -.root :global(.scan-settings-card) { - padding: 18px; -} - .root :global(.scan-summary-card.wide) { grid-column: span 1; } @@ -5459,1134 +2923,3 @@ transform: scaleY(1); } } - -.root :global(.scan-terminal.light) { - background: - radial-gradient(circle at top, rgba(59, 130, 246, 0.1), transparent 34%), - linear-gradient(180deg, #F7F9FC 0%, #EEF2F7 100%); - color: #0F172A; -} - -.root :global(.scan-terminal.light .scan-filter-panel), -.root :global(.scan-terminal.light .scan-data-grid), -.root :global(.scan-terminal.light .scan-detail-panel), -.root :global(.scan-terminal.light > .detail-panel.scan-city-detail-rail) { - border-color: #E2E8F0; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.96)); - box-shadow: 0 16px 34px rgba(40, 70, 110, 0.12); -} - -.root :global(.scan-terminal.light .scan-sidebar-brand-name), -.root :global(.scan-terminal.light .scan-topbar-tab.active), -.root :global(.scan-terminal.light .scan-hero h1), -.root :global(.scan-terminal.light .scan-topbar-title strong), -.root :global(.scan-terminal.light .scan-city-name), -.root :global(.scan-terminal.light .scan-opportunity-city strong), -.root :global(.scan-terminal.light .panel-title-area h2), -.root :global(.scan-terminal.light .detail-summary-temp), -.root :global(.scan-terminal.light .scan-detail-city-name), -.root :global(.scan-terminal.light .scan-empty-title), -.root :global(.scan-terminal.light .scan-detail-section-title), -.root :global(.scan-terminal.light .scan-detail-volume-big), -.root :global(.scan-terminal.light .scan-kv strong), -.root :global(.scan-terminal.light .scan-time-main), -.root :global(.scan-terminal.light .scan-calendar-date), -.root :global(.scan-terminal.light .scan-summary-value), -.root :global(.scan-terminal.light .scan-distribution-card strong), -.root :global(.scan-terminal.light .scan-table-header), -.root :global(.scan-terminal.light .scan-list-tabs button.active) { - color: #0F172A; -} - -.root :global(.scan-terminal.light .scan-filter-heading), -.root :global(.scan-terminal.light .scan-topbar-actions), -.root :global(.scan-terminal.light .scan-topbar-time), -.root :global(.scan-terminal.light .scan-hero p), -.root :global(.scan-terminal.light .scan-topbar-title span), -.root :global(.scan-terminal.light .scan-city-sub), -.root :global(.scan-terminal.light .scan-opportunity-phase), -.root :global(.scan-terminal.light .scan-opportunity-phase em), -.root :global(.scan-terminal.light .panel-overline), -.root :global(.scan-terminal.light .detail-summary-supporting), -.root :global(.scan-terminal.light .detail-value-muted), -.root :global(.scan-terminal.light .detail-empty-state), -.root :global(.scan-terminal.light .scan-time-remaining), -.root :global(.scan-terminal.light .scan-detail-city-sub), -.root :global(.scan-terminal.light .scan-detail-volume-caption), -.root :global(.scan-terminal.light .scan-empty-copy), -.root :global(.scan-terminal.light .scan-loading-copy-block span), -.root :global(.scan-terminal.light .scan-kv span:first-child), -.root :global(.scan-terminal.light .scan-chart-label), -.root :global(.scan-terminal.light .scan-trade-sub), -.root :global(.scan-terminal.light .scan-trade-note) { - color: #475569; -} - -.root :global(.scan-terminal.light .scan-loading-copy-block strong) { - color: #0f172a; -} - -.root :global(.scan-terminal.light .scan-loading-signal) { - border-color: #dbeafe; - background: - radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.12), transparent 34%), - linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(241, 245, 249, 0.9)); - box-shadow: 0 16px 34px rgba(40, 70, 110, 0.1); -} - -.root :global(.scan-terminal.light .scan-loading-signal.compact) { - background: - radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.1), transparent 34%), - rgba(255, 255, 255, 0.78); -} - -.root :global(.scan-terminal.light .scan-loading-node) { - background: #ffffff; - border-color: rgba(148, 163, 184, 0.22); -} - -.root :global(.scan-terminal.light .scan-loading-node.hot) { - background: linear-gradient(135deg, #f97316, #fde047); -} - -.root :global(.scan-terminal.light .scan-loading-node.market) { - background: linear-gradient(135deg, #38bdf8, #2563eb); -} - -.root :global(.scan-terminal.light .scan-loading-node.action) { - background: linear-gradient(135deg, #16a34a, #22c55e); -} - -.root :global(.scan-terminal.light .scan-list-tabs button), -.root :global(.scan-terminal.light .scan-mode-tab-sub), -.root :global(.scan-terminal.light .scan-opportunity-models em), -.root :global(.scan-terminal.light .scan-opportunity-stat small), -.root :global(.scan-terminal.light .panel-meta-chip), -.root :global(.scan-terminal.light .panel-weather-chip), -.root :global(.scan-terminal.light .scan-distribution-card), -.root :global(.scan-terminal.light .scan-chart-legend), -.root :global(.scan-terminal.light .scan-kpi-note) { - color: #94A3B8; -} - -.root :global(.scan-terminal.light .scan-distribution-line em) { - color: #243b5a; -} - -.root :global(.scan-terminal.light .scan-distribution-line b) { - color: #6f86a4; -} - -.root :global(.scan-terminal.light .scan-mode-tab), -.root :global(.scan-terminal.light .scan-table-shell), -.root :global(.scan-terminal.light .scan-table-header), -.root :global(.scan-terminal.light .scan-kpi-card), -.root :global(.scan-terminal.light .scan-calendar-group), -.root :global(.scan-terminal.light .scan-calendar-card), -.root :global(.scan-terminal.light .scan-summary-card), -.root :global(.scan-terminal.light .scan-settings-card), -.root :global(.scan-terminal.light .scan-distribution-card), -.root :global(.scan-terminal.light .scan-opportunity-group), -.root :global(.scan-terminal.light .detail-summary-shell), -.root :global(.scan-terminal.light .detail-structured-section), -.root :global(.scan-terminal.light .detail-card), -.root :global(.scan-terminal.light .scan-trade-card) { - border-color: #E2E8F0; - background: #FFFFFF; -} - -.root :global(.scan-terminal.light .scan-mode-tab.active) { - color: #1D4ED8; - background: #DBEAFE; - border-color: rgba(59, 130, 246, 0.34); - box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.16); -} - -.root :global(.scan-terminal.light .scan-mode-tab.active .scan-mode-tab-label) { - color: #1D4ED8; -} - -.root :global(.scan-terminal.light .scan-mode-tab.active .scan-mode-tab-sub) { - color: #475569; -} - -.root :global(.scan-terminal.light .scan-calendar-subtitle), -.root :global(.scan-terminal.light .scan-calendar-action span), -.root :global(.scan-terminal.light .scan-calendar-countdown small) { - color: #647a98; -} - -.root :global(.scan-terminal.light .scan-calendar-action b) { - color: #122033; -} - -.root :global(.scan-terminal.light .scan-calendar-reason) { - color: #243b5a; -} - -.root :global(.scan-terminal.light .scan-calendar-countdown) { - border-color: rgba(10, 160, 100, 0.18); - background: rgba(220, 252, 239, 0.68); - color: #087b55; -} - -.root :global(.scan-terminal.light .scan-calendar-card.peak-active) { - border-color: rgba(10, 160, 100, 0.34); - background: linear-gradient(180deg, rgba(220, 252, 239, 0.84), rgba(255, 255, 255, 0.86)); -} - -.root :global(.scan-terminal.light .scan-mode-tab.active .scan-mode-icon) { - background: rgba(16, 185, 129, 0.16); - color: #069668; -} - -.root :global(.scan-terminal.light .scan-table-row.selected) { - background: linear-gradient(180deg, rgba(228, 252, 242, 0.92), rgba(241, 250, 247, 0.96)); - border-color: rgba(10, 160, 100, 0.34); - box-shadow: inset 0 0 0 1px rgba(10, 160, 100, 0.18); -} - -.root :global(.scan-terminal.light .scan-table-row) { - background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(247, 250, 254, 0.9)); - border-bottom-color: rgba(35, 72, 118, 0.08); -} - -.root :global(.scan-terminal.light .scan-table-row:hover) { - background: linear-gradient(180deg, rgba(248, 252, 255, 0.96), rgba(238, 247, 255, 0.96)); -} - -.root :global(.scan-terminal.light .scan-kpi-card) { - box-shadow: 0 10px 24px rgba(40, 70, 110, 0.08); -} - -.root :global(.scan-terminal.light .scan-kpi-value), -.root :global(.scan-terminal.light .scan-opportunity-phase > span) { - color: #14253a; -} - -.root :global(.scan-terminal.light .scan-opportunity-group-head) { - background: rgba(246, 250, 255, 0.78); - border-bottom-color: rgba(35, 72, 118, 0.1); -} - -.root :global(.scan-terminal.light .scan-opportunity-item) { - background: rgba(255, 255, 255, 0.62); - color: #405977; - border-bottom-color: rgba(35, 72, 118, 0.08); - border-color: rgba(35, 72, 118, 0.1); -} - -.root :global(.scan-terminal.light .scan-opportunity-item:hover) { - background: rgba(230, 242, 255, 0.76); -} - -.root :global(.scan-terminal.light .scan-opportunity-item.selected) { - background: linear-gradient(180deg, rgba(222, 250, 238, 0.9), rgba(241, 250, 247, 0.96)); - border-color: rgba(10, 160, 100, 0.34); -} - -.root :global(.scan-terminal.light .scan-opportunity-group.selected) { - border-color: rgba(10, 160, 100, 0.34); - box-shadow: inset 0 0 0 1px rgba(10, 160, 100, 0.18); -} - -.root :global(.scan-terminal.light .scan-opportunity-models span) { - border-color: rgba(35, 72, 118, 0.1); - background: rgba(255, 255, 255, 0.68); -} - -.root :global(.scan-terminal.light .scan-opportunity-stat) { - border-color: rgba(35, 72, 118, 0.1); - background: rgba(248, 252, 255, 0.76); -} - -.root :global(.scan-terminal.light .scan-opportunity-models b), -.root :global(.scan-terminal.light .scan-opportunity-stat b) { - color: #14253a; -} - -.root :global(.scan-terminal.light .scan-opportunity-branch::before), -.root :global(.scan-terminal.light .scan-opportunity-branch i) { - background: rgba(56, 86, 126, 0.24); -} - -.root :global(.scan-terminal.light .panel-header), -.root :global(.scan-terminal.light .panel-body section) { - border-color: rgba(35, 72, 118, 0.1); -} - -.root :global(.scan-terminal.light .panel-action-button-secondary), -.root :global(.scan-terminal.light .panel-action-button-ghost) { - border-color: rgba(35, 72, 118, 0.14); - background: rgba(255, 255, 255, 0.76); -} - -.root :global(.scan-terminal.light .detail-summary-shell-live) { - background: linear-gradient(180deg, rgba(224, 247, 255, 0.82), rgba(241, 248, 255, 0.92)); -} - -.root :global(.scan-terminal.light .scan-locale-switch), -.root :global(.scan-terminal.light .scan-ghost-button), -.root :global(.scan-terminal.light .scan-theme-button), -.root :global(.scan-terminal.light .scan-sort-pill), -.root :global(.scan-terminal.light .scan-icon-pill), -.root :global(.scan-terminal.light .scan-account-button), -.root :global(.scan-terminal.light .scan-detail-action-button) { - border-color: rgba(35, 72, 118, 0.14); - background: rgba(255, 255, 255, 0.78); - color: #1f3654; -} - -.root :global(.scan-terminal.light .scan-primary-button) { - color: #FFFFFF; - border-color: rgba(59, 130, 246, 0.34); - background: linear-gradient(180deg, #3B82F6, #2563EB); -} - -.root :global(.scan-terminal.light .scan-ai-button) { - color: #1D4ED8; - border-color: rgba(59, 130, 246, 0.28); - background: #DBEAFE; -} - -.root :global(.scan-terminal.light .scan-opportunity-ai) { - border-color: rgba(35, 72, 118, 0.1); - background: rgba(248, 252, 255, 0.76); -} - -.root :global(.scan-terminal.light .scan-opportunity-ai small) { - color: #647a98; -} - -.root :global(.scan-terminal.light .scan-opportunity-strength) { - border-color: rgba(35, 72, 118, 0.12); - background: rgba(248, 252, 255, 0.78); -} - -.root :global(.scan-terminal.light .scan-opportunity-expand) { - color: #1d4ed8; - border-color: rgba(37, 99, 235, 0.18); - background: rgba(239, 246, 255, 0.84); -} - -.root :global(.scan-terminal.light .scan-v4-analysis), -.root :global(.scan-terminal.light .scan-v4-analysis section), -.root :global(.scan-terminal.light .scan-v4-evidence > div > span), -.root :global(.scan-terminal.light .scan-v4-model-sources span) { - border-color: rgba(35, 72, 118, 0.12); - background: rgba(255, 255, 255, 0.76); -} - -.root :global(.scan-terminal.light .scan-v4-analysis strong) { - color: #057a6b; -} - -.root :global(.scan-terminal.light .scan-v4-analysis p), -.root :global(.scan-terminal.light .scan-v4-analysis ul), -.root :global(.scan-terminal.light .scan-v4-evidence > div > span) { - color: #6B7A90; -} - -.root :global(.scan-terminal.light .scan-v4-model-sources em) { - color: #6B7A90; -} - -.root :global(.scan-terminal.light .scan-v4-model-sources b) { - color: #0f172a; -} - -.root :global(.scan-terminal.light .scan-forecast-city-card), -.root :global(.scan-terminal.light .scan-forecast-row), -.root :global(.scan-terminal.light .scan-ai-analysis) { - border-color: #E2E8F0; - background: #FFFFFF; -} - -.root :global(.scan-terminal.light .scan-forecast-city-head) { - background: #EEF2F7; -} - -.root :global(.scan-terminal.light .scan-forecast-city-card.selected), -.root :global(.scan-terminal.light .scan-forecast-city-card:has(.scan-forecast-row.selected)) { - border-color: rgba(59, 130, 246, 0.38); - box-shadow: - inset 0 0 0 1px rgba(59, 130, 246, 0.12), - 0 16px 34px rgba(40, 70, 110, 0.12); -} - -.root :global(.scan-terminal.light .scan-forecast-row.selected), -.root :global(.scan-terminal.light .scan-forecast-row.expanded) { - border-color: rgba(59, 130, 246, 0.38); - background: - linear-gradient(180deg, rgba(219, 234, 254, 0.62), rgba(255, 255, 255, 0.98)), - #FFFFFF; - box-shadow: inset 4px 0 0 #3B82F6; -} - -.root :global(.scan-terminal.light .scan-forecast-city-title strong), -.root :global(.scan-terminal.light .scan-forecast-city-read > b:not(.scan-phase-badge)), -.root :global(.scan-terminal.light .scan-forecast-bucket strong), -.root :global(.scan-terminal.light .scan-forecast-signals b), -.root :global(.scan-terminal.light .scan-ai-temperature-line b) { - color: #0F172A; -} - -.root :global(.scan-terminal.light .scan-forecast-city-chips span), -.root :global(.scan-terminal.light .scan-forecast-signals span), -.root :global(.scan-terminal.light .scan-ai-evidence-line span) { - border-color: #E2E8F0; - background: #EEF2F7; -} - -.root :global(.scan-terminal.light .scan-forecast-city-chips span), -.root :global(.scan-terminal.light .scan-forecast-city-read span), -.root :global(.scan-terminal.light .scan-forecast-bucket small), -.root :global(.scan-terminal.light .scan-forecast-ai-line small), -.root :global(.scan-terminal.light .scan-ai-brief-grid li), -.root :global(.scan-terminal.light .scan-ai-airport-read p) { - color: #475569; -} - -.root :global(.scan-terminal.light .scan-ai-analysis-head p) { - color: #0F172A; -} - -.root :global(.scan-terminal.light .scan-ai-analysis-head small), -.root :global(.scan-terminal.light .scan-ai-evidence-line b) { - color: #475569; -} - -.root :global(.scan-terminal.light .scan-ai-workspace) { - background: #f7f9fc; - border-color: #e2e8f0; -} - -.root :global(.scan-terminal.light .scan-opportunity-overview) { - background: #f7f9fc; - border-color: #e2e8f0; -} - -.root :global(.scan-terminal.light .scan-opportunity-hero > div:first-child), -.root :global(.scan-terminal.light .scan-opportunity-summary span), -.root :global(.scan-terminal.light .scan-opportunity-lane), -.root :global(.scan-terminal.light .scan-opportunity-decision-card), -.root :global(.scan-terminal.light .scan-opportunity-decision-primary span) { - background: #ffffff; - border-color: #e2e8f0; -} - -.root :global(.scan-terminal.light .scan-opportunity-hero strong), -.root :global(.scan-terminal.light .scan-opportunity-summary b), -.root :global(.scan-terminal.light .scan-opportunity-lane-head strong), -.root :global(.scan-terminal.light .scan-opportunity-decision-head strong), -.root :global(.scan-terminal.light .scan-opportunity-decision-primary b) { - color: #0f172a; -} - -.root :global(.scan-terminal.light .scan-opportunity-hero p), -.root :global(.scan-terminal.light .scan-opportunity-summary span), -.root :global(.scan-terminal.light .scan-opportunity-lane-head p), -.root :global(.scan-terminal.light .scan-opportunity-decision-card p), -.root :global(.scan-terminal.light .scan-opportunity-decision-head span), -.root :global(.scan-terminal.light .scan-opportunity-decision-primary span), -.root :global(.scan-terminal.light .scan-opportunity-decision-foot small) { - color: #64748b; -} - -.root :global(.scan-terminal.light .scan-ai-city-card), -.root :global(.scan-terminal.light .scan-ai-city-hero) { - background: #ffffff; - border-color: #e2e8f0; -} - -.root :global(.scan-terminal.light .scan-ai-city-section), -.root :global(.scan-terminal.light .scan-ai-decision-band), -.root :global(.scan-terminal.light .scan-ai-market-decision), -.root :global(.scan-terminal.light .scan-ai-market-decision-stats small), -.root :global(.scan-terminal.light .scan-ai-decision-metrics span), -.root :global(.scan-terminal.light .scan-ai-market-bucket), -.root :global(.scan-terminal.light .scan-ai-city-pills span), -.root :global(.scan-terminal.light .scan-ai-city-freshness span) { - background: #eef2f7; - border-color: #e2e8f0; -} - -.root :global(.scan-terminal.light .scan-ai-workspace-head strong), -.root :global(.scan-terminal.light .scan-ai-city-hero h3), -.root :global(.scan-terminal.light .scan-ai-city-hero-side > strong), -.root :global(.scan-terminal.light .scan-ai-decision-band strong), -.root :global(.scan-terminal.light .scan-ai-decision-metrics b), -.root :global(.scan-terminal.light .scan-ai-market-bucket strong), -.root :global(.scan-terminal.light .scan-ai-weather-summary), -.root :global(.scan-terminal.light .scan-ai-decision-reasons small), -.root :global(.scan-terminal.light .scan-ai-market-decision strong), -.root :global(.scan-terminal.light .scan-ai-market-decision-stats b) { - color: #0f172a; -} - -.root :global(.scan-terminal.light .scan-ai-workspace-head p), -.root :global(.scan-terminal.light .scan-ai-city-section p), -.root :global(.scan-terminal.light .scan-ai-decision-band p), -.root :global(.scan-terminal.light .scan-ai-city-pills span), -.root :global(.scan-terminal.light .scan-ai-city-freshness), -.root :global(.scan-terminal.light .scan-ai-decision-metrics span), -.root :global(.scan-terminal.light .scan-ai-market-bucket span), -.root :global(.scan-terminal.light .scan-ai-market-decision span), -.root :global(.scan-terminal.light .scan-ai-market-decision p), -.root :global(.scan-terminal.light .scan-ai-market-decision-stats small), -.root :global(.scan-terminal.light .scan-ai-city-muted), -.root :global(.scan-terminal.light .scan-ai-city-loading), -.root :global(.scan-terminal.light .scan-ai-city-chart-legend), -.root :global(.scan-terminal.light .scan-ai-weather-bullets) { - color: #475569; -} - -.root :global(.scan-terminal.light .scan-ai-raw-metar) { - border-top-color: #e2e8f0; - color: #64748b; -} - -.root :global(.scan-terminal.light .scan-ai-city-chart-placeholder) { - border-color: #cbd5e1; - background: #f8fafc; - color: #64748b; -} - -.root :global(.scan-terminal.light .scan-ai-log-panel) { - border-color: rgba(35, 72, 118, 0.12); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgba(246, 250, 255, 0.9)); -} - -.root :global(.scan-terminal.light .scan-ai-log-panel.compact) { - background: rgba(255, 255, 255, 0.82); -} - -.root :global(.scan-terminal.light .scan-ai-log-panel.compact .scan-ai-log-summary span) { - color: #334155; -} - -.root :global(.scan-terminal.light .scan-ai-log-panel.compact .scan-ai-log-summary strong) { - color: #2563eb; -} - -.root :global(.scan-terminal.light .scan-ai-log-head strong), -.root :global(.scan-terminal.light .scan-ai-log-item b) { - color: #122033; -} - -.root :global(.scan-terminal.light .scan-ai-log-head span), -.root :global(.scan-terminal.light .scan-ai-log-time), -.root :global(.scan-terminal.light .scan-ai-log-empty) { - color: #647a98; -} - -.root :global(.scan-terminal.light .scan-ai-log-item) { - border-color: rgba(35, 72, 118, 0.12); - background: rgba(255, 255, 255, 0.72); -} - -.root :global(.scan-terminal.light .scan-ai-log-item small) { - color: #58708f; -} - -.root :global(.scan-terminal.light .scan-ai-log-item.info) { - border-color: rgba(14, 165, 233, 0.24); -} - -.root :global(.scan-terminal.light .scan-ai-log-item.success) { - border-color: rgba(10, 160, 100, 0.28); -} - -.root :global(.scan-terminal.light .scan-ai-log-item.warning) { - border-color: rgba(217, 119, 6, 0.28); -} - -.root :global(.scan-terminal.light .scan-ai-log-item.error) { - border-color: rgba(220, 38, 38, 0.28); -} - -.root :global(.scan-terminal.light .scan-ai-summary-card), -.root :global(.scan-terminal.light .scan-ai-city-card) { - background: rgba(255, 255, 255, 0.92); - border-color: rgba(148, 163, 184, 0.28); -} - -.root :global(.scan-terminal.light .scan-ai-summary-card strong), -.root :global(.scan-terminal.light .scan-ai-city-head strong), -.root :global(.scan-terminal.light .scan-ai-contract b) { - color: #0f172a; -} - -.root :global(.scan-terminal.light .scan-ai-summary-card p), -.root :global(.scan-terminal.light .scan-ai-city-head p), -.root :global(.scan-terminal.light .scan-ai-contract p), -.root :global(.scan-terminal.light .scan-ai-contract small) { - color: #6B7A90; -} - -.root :global(.scan-terminal.light .scan-ai-city-head) { - background: #f8fafc; -} - -.root :global(.scan-terminal.light .scan-ai-cluster-note) { - color: #334155; - background: #f8fafc; - border-color: rgba(148, 163, 184, 0.26); -} - -.root :global(.scan-terminal.light .scan-ai-contract) { - border-top-color: rgba(148, 163, 184, 0.2); -} - -.root :global(.scan-terminal.light .scan-ai-inline-button) { - color: #064e3b; - background: #d1fae5; - border-color: rgba(16, 185, 129, 0.28); -} - -.root :global(.scan-terminal.light .scan-cta-ghost), -.root :global(.scan-terminal.light .scan-detail-analysis-button) { - border-color: rgba(7, 160, 100, 0.24); - background: rgba(11, 188, 116, 0.12); - color: #057a4d; -} - -.root :global(.scan-terminal.light .scan-score-ring::after) { - background: #f5f9fe; -} - -.root :global(.scan-terminal.light .scan-score-ring span) { - color: #122033; -} - -.root :global(.scan-terminal.light .scan-trade-card p) { - color: #405977; -} - -.root :global(.scan-terminal.light .scan-map-shell) { - background: - radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.12), transparent 34%), - linear-gradient(180deg, #f8fbff, #eaf3ff); - border-color: rgba(37, 99, 235, 0.16); - box-shadow: 0 16px 34px rgba(40, 70, 110, 0.12); -} - -.root :global(.scan-terminal.light .scan-map-shell .map), -.root :global(.scan-terminal.light .scan-map-shell .leaflet-container) { - background: #eaf3ff !important; -} - -.root :global(.scan-terminal.light .scan-map-shell .leaflet-tile) { - filter: saturate(1.06) contrast(0.98) brightness(1.04) !important; -} - -.root :global(.scan-terminal.light .scan-map-caption) { - color: #475569; -} - -.root :global(.scan-terminal.light .scan-list-tabs button), -.root :global(.scan-terminal.light .scan-mode-tab-sub), -.root :global(.scan-terminal.light .scan-opportunity-models em), -.root :global(.scan-terminal.light .scan-opportunity-stat small), -.root :global(.scan-terminal.light .panel-meta-chip), -.root :global(.scan-terminal.light .panel-weather-chip), -.root :global(.scan-terminal.light .scan-distribution-card), -.root :global(.scan-terminal.light .scan-chart-legend), -.root :global(.scan-terminal.light .scan-kpi-note), -.root :global(.scan-terminal.light .scan-calendar-subtitle), -.root :global(.scan-terminal.light .scan-calendar-action span), -.root :global(.scan-terminal.light .scan-calendar-countdown small), -.root :global(.scan-terminal.light .scan-opportunity-ai small), -.root :global(.scan-terminal.light .scan-ai-log-head span), -.root :global(.scan-terminal.light .scan-ai-log-time), -.root :global(.scan-terminal.light .scan-ai-log-empty), -.root :global(.scan-terminal.light .scan-ai-log-item small), -.root :global(.scan-terminal.light .scan-ai-summary-card p), -.root :global(.scan-terminal.light .scan-ai-city-head p), -.root :global(.scan-terminal.light .scan-ai-contract p), -.root :global(.scan-terminal.light .scan-ai-contract small), -.root :global(.scan-terminal.light .scan-v4-analysis p), -.root :global(.scan-terminal.light .scan-v4-analysis ul), -.root :global(.scan-terminal.light .scan-v4-evidence > div > span), -.root :global(.scan-terminal.light .scan-v4-model-sources em), -.root :global(.scan-terminal.light .scan-ai-raw-metar), -.root :global(.scan-terminal.light .scan-opportunity-hero p), -.root :global(.scan-terminal.light .scan-opportunity-summary span), -.root :global(.scan-terminal.light .scan-opportunity-lane-head p), -.root :global(.scan-terminal.light .scan-opportunity-decision-card p), -.root :global(.scan-terminal.light .scan-opportunity-decision-head span), -.root :global(.scan-terminal.light .scan-opportunity-decision-primary span), -.root :global(.scan-terminal.light .scan-opportunity-decision-foot small) { - color: #475569; -} - -@media (max-width: 1480px) { - .root :global(.scan-terminal) { - grid-template-columns: minmax(0, 1fr); - } - - .root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) { - position: relative; - top: auto; - grid-column: 1 / -1; - height: auto; - max-height: none; - min-height: auto; - } - - .root :global(.scan-detail-panel) { - grid-column: 1 / -1; - min-height: auto; - } -} - -@media (max-width: 1100px) { - .root :global(.scan-terminal) { - grid-template-columns: 1fr; - } - - .root :global(.scan-filter-panel), - .root :global(.scan-data-grid), - .root :global(.scan-detail-panel), - .root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) { - min-height: auto; - } - - .root :global(.scan-kpi-bar) { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .root :global(.scan-topbar) { - flex-direction: column; - align-items: flex-start; - } - - .root :global(.scan-upgrade-announcement) { - grid-template-columns: 1fr; - } - - .root :global(.scan-upgrade-announcement ul) { - justify-content: flex-start; - } - - .root :global(.scan-table-header), - .root :global(.scan-table-row) { - grid-template-columns: 1fr; - } - - .root :global(.scan-table-header) { - display: none; - } - - .root :global(.scan-opportunity-group-head), - .root :global(.scan-opportunity-item) { - grid-template-columns: 1fr; - } - - .root :global(.scan-forecast-city-head), - .root :global(.scan-forecast-row-main), - .root :global(.scan-ai-brief-grid), - .root :global(.scan-ai-city-analysis-grid), - .root :global(.scan-ai-decision-band), - .root :global(.scan-ai-evidence-line) { - grid-template-columns: 1fr; - } - - .root :global(.scan-ai-workspace-head), - .root :global(.scan-opportunity-hero), - .root :global(.scan-ai-city-hero), - .root :global(.scan-ai-city-section-head) { - flex-direction: column; - align-items: flex-start; - } - - .root :global(.scan-opportunity-hero) { - display: flex; - } - - .root :global(.scan-ai-workspace-head p), - .root :global(.scan-ai-city-hero-side) { - text-align: left; - justify-items: start; - } - - .root :global(.scan-ai-decision-metrics) { - min-width: 0; - grid-template-columns: 1fr; - } - - .root :global(.scan-ai-market-decision) { - grid-template-columns: 1fr; - } - - .root :global(.scan-opportunity-card-grid), - .root :global(.scan-opportunity-summary) { - grid-template-columns: 1fr; - } - - .root :global(.scan-forecast-city-read) { - justify-items: start; - text-align: left; - } - - .root :global(.scan-forecast-signals), - .root :global(.scan-ai-temperature-line) { - grid-template-columns: 1fr; - } - - .root :global(.scan-opportunity-phase) { - flex-wrap: wrap; - } - - .root :global(.scan-opportunity-branch) { - display: none; - } - - .root :global(.scan-opportunity-stat.edge) { - justify-self: start; - justify-items: start; - } - - .root :global(.scan-opportunity-ai), - .root :global(.scan-v4-analysis) { - grid-column: 1 / -1; - } - - .root :global(.scan-v4-analysis) { - grid-template-columns: 1fr; - } - - .root :global(.scan-trade-cards) { - grid-template-columns: 1fr; - } -} - -@media (max-width: 820px) { - .root :global(.scan-terminal) { - padding: 10px; - } - - .root :global(.scan-kpi-bar) { - grid-template-columns: 1fr; - } - - .root :global(.scan-topbar-tabs) { - gap: 14px; - flex-wrap: wrap; - } - - .root :global(.scan-upgrade-announcement) { - padding: 14px; - } - - .root :global(.scan-upgrade-announcement-copy strong) { - font-size: 16px; - } - - .root :global(.scan-upgrade-announcement ul) { - gap: 6px; - } - - .root :global(.scan-upgrade-announcement li) { - font-size: 11px; - padding: 6px 8px; - } - - .root :global(.scan-ai-city-hero) { - padding: 16px; - } - - .root :global(.scan-ai-city-hero h3) { - margin-bottom: 10px; - font-size: 24px; - } - - .root :global(.scan-ai-city-mobile-priority) { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; - margin: 0 0 12px; - } - - .root :global(.scan-ai-city-mobile-priority span) { - display: grid; - gap: 4px; - border: 1px solid rgba(77, 163, 255, 0.18); - border-radius: 12px; - background: rgba(77, 163, 255, 0.09); - padding: 9px; - } - - .root :global(.scan-ai-city-mobile-priority small) { - color: #9fb2c7; - font-size: 10px; - font-weight: 900; - } - - .root :global(.scan-ai-city-mobile-priority b) { - color: #f3f8ff; - font-size: 13px; - line-height: 1.15; - } - - .root :global(.scan-ai-city-pills) { - display: none; - } - - .root :global(.scan-ai-city-freshness) { - grid-template-columns: 1fr; - } - - .root :global(.scan-ai-decision-band) { - padding: 14px; - } - - .root :global(.scan-ai-decision-band strong) { - font-size: 20px; - } - - .root :global(.scan-ai-decision-long), - .root :global(.scan-ai-decision-reasons), - .root :global(.scan-ai-decision-risk) { - display: none; - } - - .root :global(.scan-ai-market-mobile-line) { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-top: 12px; - border: 1px solid rgba(77, 163, 255, 0.18); - border-radius: 12px; - background: rgba(11, 18, 32, 0.48); - padding: 10px 12px; - } - - .root :global(.scan-ai-market-mobile-line span) { - color: #9fb2c7; - font-size: 12px; - font-weight: 900; - } - - .root :global(.scan-ai-market-mobile-line b) { - color: #e6edf3; - font-size: 13px; - text-align: right; - } - - .root :global(.scan-ai-market-decision) { - margin-top: 10px; - } - - .root :global(.scan-ai-city-ai-read:not([open])) { - padding-bottom: 4px; - } - - .root :global(.scan-list-header) { - flex-direction: column; - align-items: flex-start; - } - - .root :global(.scan-opportunity-groups) { - padding: 12px; - } - - .root :global(.scan-opportunity-models span) { - white-space: normal; - } - - .root :global(.scan-forecast-city-title strong) { - font-size: 21px; - } - - .root :global(.scan-forecast-row-main), - .root :global(.scan-ai-analysis) { - padding: 13px; - } - - .root :global(.scan-forecast-ai-line) { - grid-template-columns: 1fr; - padding: 0 13px 13px; - } -} - -/* Light-mode hard overrides for the scan terminal. Keep this block last so the - decision workspace cannot inherit the dark map/card palette from base rules. */ -.root:global(.light) { - --bg-primary: #eef7ff; - --bg-secondary: #e0f2fe; - --bg-card: #ffffff; - --bg-glass: rgba(255, 255, 255, 0.92); - --border-glass: #cfe8ff; - --border-subtle: rgba(125, 171, 214, 0.34); - --text-primary: #0f172a; - --text-secondary: #334155; - --text-muted: #475569; -} - -.root:global(.light) :global(.scan-terminal.light) { - background: - radial-gradient(circle at 18% 12%, rgba(59, 130, 246, 0.12), transparent 30%), - linear-gradient(180deg, #eef7ff 0%, #e8f4ff 48%, #f8fbff 100%) !important; - color: #0f172a !important; -} - -:global(html.light) .root :global(.map), -.root:global(.light) :global(.map), -.root:global(.light) :global(.scan-terminal.light .scan-map-shell .map), -.root:global(.light) :global(.scan-terminal.light .scan-map-shell .leaflet-container), -.root:global(.light) :global(.leaflet-container), -.root:global(.light) :global(.leaflet-pane), -.root:global(.light) :global(.leaflet-map-pane), -.root:global(.light) :global(.leaflet-tile-pane) { - background: #eef7ff !important; -} - -:global(html.light) .root :global(.map), -.root:global(.light) :global(.scan-terminal.light .scan-map-shell) { - border-color: rgba(37, 99, 235, 0.18) !important; - box-shadow: - 0 18px 40px rgba(61, 100, 145, 0.14), - inset 0 0 0 1px rgba(255, 255, 255, 0.72) !important; -} - -:global(html.light) .root :global(.map .leaflet-tile), -.root:global(.light) :global(.map .leaflet-tile), -.root:global(.light) :global(.scan-terminal.light .scan-map-shell .leaflet-tile) { - filter: none !important; - opacity: 1 !important; -} - -.root:global(.light) :global(.scan-city-detail-rail), -.root:global(.light) :global(.scan-terminal.light > .detail-panel.scan-city-detail-rail) { - background: - linear-gradient(180deg, rgba(238, 247, 255, 0.98), rgba(255, 255, 255, 0.96)) !important; - border-color: rgba(37, 99, 235, 0.16) !important; - color: #0f172a !important; -} - -.root:global(.light) :global(.scan-city-detail-rail .panel-header), -.root:global(.light) :global(.scan-city-detail-rail .detail-structured-section), -.root:global(.light) :global(.scan-city-detail-rail .detail-summary-shell), -.root:global(.light) :global(.scan-city-detail-rail .detail-card) { - background: rgba(255, 255, 255, 0.74) !important; - border-color: rgba(125, 171, 214, 0.28) !important; -} - -.root:global(.light) :global(.scan-city-detail-rail h2), -.root:global(.light) :global(.scan-city-detail-rail h3), -.root:global(.light) :global(.scan-city-detail-rail strong), -.root:global(.light) :global(.scan-city-detail-rail .detail-value), -.root:global(.light) :global(.scan-city-detail-rail .detail-summary-temp), -.root:global(.light) :global(.scan-city-detail-rail .detail-section-head h3), -.root:global(.light) :global(.scan-city-detail-rail .panel-title-area h2) { - color: #0f172a !important; -} - -.root:global(.light) :global(.scan-city-detail-rail p), -.root:global(.light) :global(.scan-city-detail-rail li), -.root:global(.light) :global(.scan-city-detail-rail small), -.root:global(.light) :global(.scan-city-detail-rail .panel-overline), -.root:global(.light) :global(.scan-city-detail-rail .panel-loading-hint), -.root:global(.light) :global(.scan-city-detail-rail .panel-meta-chip), -.root:global(.light) :global(.scan-city-detail-rail .detail-section-kicker), -.root:global(.light) :global(.scan-city-detail-rail .detail-label), -.root:global(.light) :global(.scan-city-detail-rail .detail-value-muted), -.root:global(.light) :global(.scan-city-detail-rail .detail-source-note), -.root:global(.light) :global(.scan-city-detail-rail .detail-source-kind), -.root:global(.light) :global(.scan-city-detail-rail .detail-mini-meta), -.root:global(.light) :global(.scan-city-detail-rail .detail-summary-supporting), -.root:global(.light) :global(.scan-city-detail-rail .scan-detail-city-sub), -.root:global(.light) :global(.scan-city-detail-rail .scan-detail-volume-caption), -.root:global(.light) :global(.scan-city-detail-rail .scan-detail-score-label), -.root:global(.light) :global(.scan-city-detail-rail .scan-detail-empty) { - color: #334155 !important; -} - -.root:global(.light) :global(.scan-city-detail-rail .panel-meta-chip), -.root:global(.light) :global(.scan-city-detail-rail .panel-weather-chip), -.root:global(.light) :global(.scan-city-detail-rail .detail-source-link), -.root:global(.light) :global(.scan-city-detail-rail .panel-action-button-secondary), -.root:global(.light) :global(.scan-city-detail-rail .panel-action-button-ghost) { - background: #eef7ff !important; - border-color: rgba(37, 99, 235, 0.18) !important; - color: #1f3654 !important; -} - -.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card), -.root:global(.light) :global(.scan-terminal.light .scan-ai-city-section), -.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-band), -.root:global(.light) :global(.scan-terminal.light .scan-ai-market-decision), -.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-metrics span), -.root:global(.light) :global(.scan-terminal.light .scan-ai-city-pills span), -.root:global(.light) :global(.scan-terminal.light .scan-ai-city-freshness span), -.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-metrics span), -.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-reason) { - background: #eef7ff !important; - border-color: rgba(37, 99, 235, 0.16) !important; -} - -.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card p), -.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card li), -.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card small), -.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card span:not(.scan-ai-city-kicker)) { - color: #334155 !important; -} - -.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.green) { - color: #047857 !important; -} - -.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.blue) { - color: #1d4ed8 !important; -} - -.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.amber) { - color: #b45309 !important; -} - -.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.red) { - color: #b91c1c !important; -} - -.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement) { - background: - radial-gradient(circle at top left, rgba(37, 99, 235, 0.12), transparent 36%), - #ffffff !important; - border-color: rgba(37, 99, 235, 0.16) !important; - box-shadow: 0 16px 36px rgba(61, 100, 145, 0.12) !important; -} - -.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement-copy strong), -.root:global(.light) :global(.scan-terminal.light .scan-ai-city-mobile-priority b), -.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-metrics b), -.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-reason), -.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-head h3), -.root:global(.light) :global(.scan-terminal.light .scan-ai-market-mobile-line b) { - color: #0f172a !important; -} - -.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement-copy p), -.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement li), -.root:global(.light) :global(.scan-terminal.light .scan-ai-city-mobile-priority small), -.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-metrics small), -.root:global(.light) :global(.scan-terminal.light .scan-ai-market-mobile-line span) { - color: #334155 !important; -} - -.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement-copy span) { - color: #047857 !important; -} - -.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement li), -.root:global(.light) :global(.scan-terminal.light .scan-ai-city-mobile-priority span), -.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-why), -.root:global(.light) :global(.scan-terminal.light .scan-ai-market-mobile-line) { - background: #eef7ff !important; - border-color: rgba(37, 99, 235, 0.16) !important; -} - -.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-why) { - color: #1d4ed8 !important; -} diff --git a/frontend/components/dashboard/ScanTerminalCalendar.module.css b/frontend/components/dashboard/ScanTerminalCalendar.module.css new file mode 100644 index 00000000..73d4acda --- /dev/null +++ b/frontend/components/dashboard/ScanTerminalCalendar.module.css @@ -0,0 +1,168 @@ +/* Scan terminal calendar/action view styles. */ + +.root :global(.scan-calendar-view) { + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 14px; + padding-right: 4px; +} + +.root :global(.scan-calendar-group) { + border: 1px solid rgba(90, 123, 166, 0.12); + border-radius: 18px; + background: rgba(8, 17, 30, 0.8); + overflow: hidden; +} + +.root :global(.scan-calendar-group-head) { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 18px; + border-bottom: 1px solid rgba(90, 123, 166, 0.1); +} + +.root :global(.scan-calendar-date) { + font-size: 16px; + font-weight: 800; + color: #eef7ff; +} + +.root :global(.scan-calendar-count) { + font-size: 13px; + color: #8fa4c3; +} + +.root :global(.scan-calendar-subtitle) { + margin-top: 4px; + color: #7f96b6; + font-size: 12px; + font-weight: 700; +} + +.root :global(.scan-calendar-grid) { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 12px; + padding: 14px; +} + +.root :global(.scan-calendar-card), +.root :global(.scan-summary-card), +.root :global(.scan-settings-card) { + border: 1px solid rgba(90, 123, 166, 0.12); + border-radius: 16px; + background: linear-gradient(180deg, rgba(10, 20, 35, 0.92), rgba(8, 17, 29, 0.92)); +} + +.root :global(.scan-calendar-card) { + text-align: left; + padding: 14px; + cursor: pointer; +} + +.root :global(.scan-calendar-card.selected) { + box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.42); +} + +.root :global(.scan-calendar-card.peak-active) { + border-color: rgba(23, 217, 139, 0.34); + background: linear-gradient(180deg, rgba(12, 42, 38, 0.9), rgba(8, 20, 30, 0.94)); +} + +.root :global(.scan-calendar-card.peak-next) { + border-color: rgba(94, 234, 212, 0.3); +} + +.root :global(.scan-calendar-card.peak-past) { + opacity: 0.78; +} + +.root :global(.scan-calendar-city) { + font-size: 16px; + font-weight: 800; + color: #eef7ff; +} + +.root :global(.scan-calendar-action) { + margin-top: 8px; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + font-size: 15px; + font-weight: 800; + color: #dcecff; +} + +.root :global(.scan-calendar-action span) { + color: #8fa4c3; + font-size: 12px; + font-weight: 800; +} + +.root :global(.scan-calendar-action b) { + color: #eef7ff; + font-size: 16px; + font-weight: 900; +} + +.root :global(.scan-calendar-action.buy) { + color: #21e391; +} + +.root :global(.scan-calendar-action.sell) { + color: #ff6e6e; +} + +.root :global(.scan-calendar-meta) { + margin-top: 10px; + display: flex; + justify-content: space-between; + gap: 12px; + color: #8fa4c3; + font-size: 13px; +} + +.root :global(.scan-calendar-countdown) { + margin-top: 12px; + padding: 10px 12px; + border: 1px solid rgba(94, 234, 212, 0.18); + border-radius: 12px; + background: rgba(20, 184, 166, 0.1); + color: #5fffe8; + font-size: 15px; + font-weight: 900; +} + +.root :global(.scan-calendar-countdown small) { + display: block; + margin-top: 4px; + color: #9fb4d2; + font-size: 12px; + font-weight: 800; +} + +.root :global(.scan-calendar-reason) { + margin: 10px 0 0; + color: #d6e4f5; + font-size: 13px; + font-weight: 760; + line-height: 1.45; +} + +.root :global(.scan-summary-grid) { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.root :global(.scan-summary-grid.monitor) { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.root :global(.scan-summary-card), +.root :global(.scan-settings-card) { + padding: 18px; +} diff --git a/frontend/components/dashboard/ScanTerminalCard.module.css b/frontend/components/dashboard/ScanTerminalCard.module.css new file mode 100644 index 00000000..781c5c35 --- /dev/null +++ b/frontend/components/dashboard/ScanTerminalCard.module.css @@ -0,0 +1,1113 @@ +/* Scan terminal city decision card and AI evidence card styles. */ + +.root :global(.scan-forecast-city-card) { + overflow: visible; + border-color: rgba(159, 178, 199, 0.14); + border-radius: 18px; + background: + linear-gradient(180deg, rgba(17, 26, 46, 0.96), rgba(11, 18, 32, 0.98)), + radial-gradient(circle at 0 0, rgba(77, 163, 255, 0.08), transparent 26%); +} + +.root :global(.scan-forecast-city-card.selected), +.root :global(.scan-forecast-city-card:has(.scan-forecast-row.selected)) { + border-color: rgba(77, 163, 255, 0.42); + box-shadow: + inset 0 0 0 1px rgba(77, 163, 255, 0.14), + 0 20px 54px rgba(0, 0, 0, 0.2); +} + +.root :global(.scan-forecast-city-head) { + grid-template-columns: minmax(0, 1fr) minmax(250px, 340px); + padding: 18px 20px; + border-bottom-color: rgba(159, 178, 199, 0.1); + background: rgba(22, 33, 58, 0.56); +} + +.root :global(.scan-forecast-city-title) { + display: grid; + gap: 8px; + min-width: 0; +} + +.root :global(.scan-forecast-kicker) { + color: #4DA3FF; + font-size: 11px; + font-weight: 950; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.root :global(.scan-forecast-city-title strong) { + color: #E6EDF3; + font-size: 24px; + font-weight: 950; + letter-spacing: 0; +} + +.root :global(.scan-forecast-city-chips) { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.root :global(.scan-forecast-city-chips span) { + min-height: 28px; + padding: 6px 9px; + border: 1px solid rgba(159, 178, 199, 0.14); + border-radius: 9px; + background: rgba(22, 33, 58, 0.56); + color: #9FB2C7; + font-size: 12px; + font-weight: 850; + line-height: 1.2; +} + +.root :global(.scan-forecast-city-read) { + display: grid; + justify-items: end; + gap: 5px; + min-width: 0; + color: #9FB2C7; + text-align: right; +} + +.root :global(.scan-forecast-city-read small) { + color: #6B7A90; + font-size: 11px; + font-weight: 950; + text-transform: uppercase; +} + +.root :global(.scan-forecast-city-read > b:not(.scan-phase-badge)) { + color: #E6EDF3; + font-size: 30px; + font-weight: 950; + line-height: 1; +} + +.root :global(.scan-forecast-city-read span) { + color: #9FB2C7; + font-size: 12px; + font-weight: 850; +} + +.root :global(.scan-forecast-row) { + gap: 0; + overflow: hidden; + border-color: rgba(159, 178, 199, 0.12); + border-radius: 16px; + background: rgba(17, 26, 46, 0.76); + transition: + background 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease; +} + +.root :global(.scan-forecast-row:hover) { + border-color: rgba(77, 163, 255, 0.28); + background: rgba(22, 33, 58, 0.88); +} + +.root :global(.scan-forecast-row.selected), +.root :global(.scan-forecast-row.expanded) { + border-color: rgba(77, 163, 255, 0.42); + background: + linear-gradient(180deg, rgba(13, 37, 66, 0.76), rgba(11, 18, 32, 0.9)), + radial-gradient(circle at 0 0, rgba(77, 163, 255, 0.12), transparent 32%); + box-shadow: inset 4px 0 0 rgba(77, 163, 255, 0.78); +} + +.root :global(.scan-forecast-row-main) { + display: grid; + grid-template-columns: minmax(260px, 1.05fr) minmax(360px, 1fr) max-content max-content; + gap: 12px; + align-items: stretch; + padding: 14px 16px; +} + +.root :global(.scan-forecast-bucket) { + display: grid; + align-content: center; + gap: 4px; + min-width: 0; +} + +.root :global(.scan-forecast-bucket span), +.root :global(.scan-forecast-bucket small), +.root :global(.scan-forecast-signals small), +.root :global(.scan-ai-temperature-line small) { + color: #6B7A90; + font-size: 11px; + font-weight: 900; + letter-spacing: 0.02em; +} + +.root :global(.scan-forecast-bucket strong) { + color: #E6EDF3; + font-size: 18px; + font-weight: 950; + line-height: 1.22; +} + +.root :global(.scan-forecast-bucket small) { + color: #9FB2C7; + line-height: 1.45; +} + +.root :global(.scan-forecast-signals) { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + min-width: 0; +} + +.root :global(.scan-forecast-signals span) { + display: grid; + align-content: center; + gap: 4px; + min-width: 0; + padding: 9px 11px; + border: 1px solid rgba(159, 178, 199, 0.12); + border-radius: 11px; + background: rgba(22, 33, 58, 0.68); +} + +.root :global(.scan-forecast-signals b) { + display: -webkit-box; + overflow: hidden; + color: #E6EDF3; + font-size: 12px; + font-weight: 900; + line-height: 1.25; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + text-overflow: ellipsis; + white-space: normal; +} + +.root :global(.scan-forecast-fit) { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 110px; + min-height: 40px; + padding: 0 13px; + border: 1px solid rgba(96, 165, 250, 0.28); + border-radius: 999px; + background: rgba(59, 130, 246, 0.1); + color: #6FB7FF; + font-size: 12px; + font-weight: 950; + white-space: nowrap; +} + +.root :global(.scan-forecast-fit.approve), +.root :global(.scan-ai-forecast-pill.approve) { + border-color: rgba(34, 197, 94, 0.34); + background: rgba(34, 197, 94, 0.12); + color: #86EFAC; +} + +.root :global(.scan-forecast-fit.downgrade), +.root :global(.scan-forecast-fit.watchlist), +.root :global(.scan-ai-forecast-pill.downgrade), +.root :global(.scan-ai-forecast-pill.watchlist) { + border-color: rgba(245, 158, 11, 0.34); + background: rgba(245, 158, 11, 0.12); + color: #F59E0B; +} + +.root :global(.scan-forecast-fit.veto), +.root :global(.scan-ai-forecast-pill.veto) { + border-color: rgba(248, 113, 113, 0.34); + background: rgba(248, 113, 113, 0.12); + color: #EF4444; +} + +.root :global(.scan-forecast-ai-line) { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 10px; + align-items: baseline; + padding: 0 16px 14px; + color: #9FB2C7; +} + +.root :global(.scan-forecast-ai-line b) { + color: #6FB7FF; + font-size: 12px; + font-weight: 950; +} + +.root :global(.scan-forecast-ai-line small) { + min-width: 0; + color: #9FB2C7; + font-size: 13px; + font-weight: 750; + line-height: 1.5; +} + +.root :global(.scan-ai-analysis) { + display: grid; + gap: 14px; + margin: 0 16px 16px; + padding: 16px; + border-top: 1px solid rgba(159, 178, 199, 0.12); + background: rgba(11, 18, 32, 0.38); +} + +.root :global(.scan-ai-analysis-head) { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.root :global(.scan-ai-analysis-head > div) { + display: grid; + gap: 7px; + min-width: 0; +} + +.root :global(.scan-ai-analysis-head strong), +.root :global(.scan-ai-brief-grid strong) { + color: #6FB7FF; + font-size: 12px; + font-weight: 950; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.root :global(.scan-ai-analysis-head p) { + margin: 0; + color: #E6EDF3; + font-size: 20px; + font-weight: 850; + line-height: 1.3; +} + +.root :global(.scan-ai-analysis-head small) { + max-width: 880px; + color: #9FB2C7; + font-size: 14px; + font-weight: 760; + line-height: 1.55; +} + +.root :global(.scan-ai-forecast-pill) { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + min-height: 32px; + padding: 0 13px; + border: 1px solid rgba(96, 165, 250, 0.28); + border-radius: 999px; + background: rgba(59, 130, 246, 0.1); + color: #6FB7FF; + font-size: 12px; + font-weight: 950; + white-space: nowrap; +} + +.root :global(.scan-ai-temperature-line) { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + padding: 13px 0; + border-top: 1px solid rgba(159, 178, 199, 0.1); + border-bottom: 1px solid rgba(159, 178, 199, 0.1); +} + +.root :global(.scan-ai-temperature-line span) { + display: grid; + gap: 4px; + min-width: 0; +} + +.root :global(.scan-ai-temperature-line b) { + overflow: hidden; + color: #E6EDF3; + font-size: 17px; + font-weight: 950; + text-overflow: ellipsis; + white-space: nowrap; +} + +.root :global(.scan-ai-evidence-line) { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.root :global(.scan-ai-evidence-line span) { + display: grid; + gap: 4px; + min-width: 0; + padding: 10px 12px; + border: 1px solid rgba(159, 178, 199, 0.1); + border-radius: 10px; + background: rgba(22, 33, 58, 0.54); +} + +.root :global(.scan-ai-evidence-line small) { + color: #6B7A90; + font-size: 11px; + font-weight: 900; +} + +.root :global(.scan-ai-evidence-line b) { + display: -webkit-box; + overflow: hidden; + color: #E6EDF3; + font-size: 13px; + font-weight: 900; + line-height: 1.3; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + text-overflow: ellipsis; + white-space: normal; +} + +.root :global(.scan-ai-city-stack) { + display: grid; + gap: 18px; +} + +.root :global(.scan-ai-city-card) { + overflow: hidden; + border: 1px solid rgba(77, 163, 255, 0.35); + border-radius: 18px; + background: #111a2e; + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.24); + opacity: 1; + transform: translateX(0); + transition: + opacity 0.24s ease, + transform 0.24s ease, + border-color 0.18s ease, + background 0.18s ease; +} + +.root :global(.scan-ai-city-card.removing) { + pointer-events: none; + opacity: 0; + transform: translateX(44px); +} + +.root :global(.scan-ai-city-hero) { + display: flex; + justify-content: space-between; + gap: 18px; + padding: 22px; + background: + linear-gradient(135deg, rgba(77, 163, 255, 0.11), rgba(34, 197, 94, 0.03)), + #111a2e; + border-bottom: 1px solid rgba(159, 178, 199, 0.12); +} + +.root :global(.scan-ai-city-kicker) { + color: #4da3ff; + font-size: 12px; + font-weight: 800; +} + +.root :global(.scan-ai-city-hero h3) { + margin: 8px 0 12px; + color: #e6edf3; + font-size: 28px; + line-height: 1.1; +} + +.root :global(.scan-ai-city-mobile-priority) { + display: none; +} + +.root :global(.scan-ai-city-pills) { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.root :global(.scan-ai-city-pills span) { + border: 1px solid rgba(159, 178, 199, 0.14); + border-radius: 10px; + background: rgba(11, 18, 32, 0.58); + color: #c9d7e8; + font-size: 12px; + font-weight: 800; + padding: 7px 10px; +} + +.root :global(.scan-ai-city-status-tags) { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin: -2px 0 10px; +} + +.root :global(.scan-ai-city-status-tag) { + border: 1px solid rgba(159, 178, 199, 0.16); + border-radius: 999px; + background: rgba(15, 23, 42, 0.62); + color: #cbd5e1; + font-size: 11px; + font-weight: 900; + letter-spacing: 0.01em; + padding: 5px 9px; +} + +.root :global(.scan-ai-city-status-tag.green) { + border-color: rgba(34, 197, 94, 0.34); + background: rgba(34, 197, 94, 0.12); + color: #86efac; +} + +.root :global(.scan-ai-city-status-tag.blue) { + border-color: rgba(77, 163, 255, 0.36); + background: rgba(77, 163, 255, 0.13); + color: #9ecbff; +} + +.root :global(.scan-ai-city-status-tag.amber) { + border-color: rgba(245, 158, 11, 0.38); + background: rgba(245, 158, 11, 0.13); + color: #fcd34d; +} + +.root :global(.scan-ai-city-status-tag.red) { + border-color: rgba(248, 113, 113, 0.4); + background: rgba(239, 68, 68, 0.13); + color: #fca5a5; +} + +.root :global(.scan-ai-city-status-tag.muted) { + color: #94a3b8; +} + +.root :global(.scan-ai-city-freshness) { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + max-width: 620px; + margin-top: 10px; + color: #9fb2c7; + font-size: 11px; + font-weight: 760; +} + +.root :global(.scan-ai-city-freshness strong) { + grid-column: 1 / -1; + color: #d8e7f8; + font-size: 11px; + font-weight: 900; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.root :global(.scan-ai-city-freshness span) { + display: inline-flex; + align-items: center; + gap: 2px; + border-radius: 8px; + background: rgba(148, 163, 184, 0.08); + padding: 4px 7px; +} + +.root :global(.scan-ai-city-freshness b) { + color: #d6e2f0; + font-style: normal; + font-weight: 900; +} + +.root :global(.scan-ai-city-freshness em) { + color: #9fb2c7; + font-style: normal; +} + +.root :global(.scan-ai-city-freshness span.fresh em), +.root :global(.scan-ai-city-freshness span.green em) { + color: #86efac; +} + +.root :global(.scan-ai-city-freshness span.loading em), +.root :global(.scan-ai-city-freshness span.blue em) { + color: #9ecbff; +} + +.root :global(.scan-ai-city-freshness span.stale em), +.root :global(.scan-ai-city-freshness span.amber em) { + color: #fcd34d; +} + +.root :global(.scan-ai-city-freshness span.red em) { + color: #fca5a5; +} + +.root :global(.scan-ai-city-hero-side) { + min-width: 180px; + display: grid; + justify-items: end; + align-content: start; + gap: 6px; + text-align: right; +} + +.root :global(.scan-ai-city-hero-side > span) { + color: #9fb2c7; + font-size: 12px; + font-weight: 800; +} + +.root :global(.scan-ai-city-hero-side > strong) { + color: #e6edf3; + font-size: 30px; + line-height: 1; +} + +.root :global(.scan-ai-city-actions) { + display: inline-flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} + +.root :global(.scan-ai-city-icon-button), +.root :global(.scan-ai-city-collapse), +.root :global(.scan-ai-city-price-button) { + min-height: 36px; + display: inline-flex; + align-items: center; + gap: 7px; + border: 1px solid rgba(77, 163, 255, 0.32); + border-radius: 10px; + background: rgba(77, 163, 255, 0.1); + color: #9ecbff; + font-size: 12px; + font-weight: 800; + cursor: pointer; + transition: background 0.18s ease, border-color 0.18s ease; +} + +.root :global(.scan-ai-city-icon-button) { + width: 36px; + justify-content: center; + padding: 0; +} + +.root :global(.scan-ai-city-icon-button.danger) { + border-color: rgba(239, 68, 68, 0.34); + background: rgba(239, 68, 68, 0.1); + color: #fca5a5; +} + +.root :global(.scan-ai-city-icon-button.danger:hover) { + border-color: rgba(239, 68, 68, 0.52); + background: rgba(239, 68, 68, 0.14); + color: #ff9aa4; +} + +.root :global(.scan-ai-city-icon-button:disabled) { + cursor: wait; + opacity: 0.68; +} + +.root :global(.scan-ai-city-icon-button .spin) { + animation: spin 1s linear infinite; +} + +.root :global(.scan-ai-city-collapse) { + padding: 8px 10px; +} + +.root :global(.scan-ai-city-card.collapsed .scan-ai-city-collapse svg) { + transform: rotate(-90deg); +} + + +.root :global(.scan-ai-city-price-button) { + padding: 9px 12px; +} + +.root :global(.scan-ai-city-icon-button:hover), +.root :global(.scan-ai-city-collapse:hover), +.root :global(.scan-ai-city-price-button:hover) { + background: rgba(77, 163, 255, 0.18); + border-color: rgba(111, 183, 255, 0.58); +} + +.root :global(.scan-ai-city-price-button:disabled) { + cursor: wait; + opacity: 0.62; +} + +.root :global(.scan-ai-city-body) { + padding: 18px 18px 36px; + max-height: none; + overflow: visible; + overscroll-behavior: contain; +} + +.root :global(.scan-ai-city-body::-webkit-scrollbar) { + width: 8px; +} + +.root :global(.scan-ai-city-body::-webkit-scrollbar-thumb) { + border-radius: 999px; + background: rgba(77, 163, 255, 0.28); +} + +.root :global(.scan-ai-decision-band) { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 18px; + align-items: center; + border: 1px solid rgba(77, 163, 255, 0.18); + border-radius: 16px; + background: #16213a; + padding: 18px; +} + +.root :global(.scan-ai-decision-band.warm) { + border-color: rgba(239, 68, 68, 0.34); +} + +.root :global(.scan-ai-decision-band.cold) { + border-color: rgba(34, 197, 94, 0.34); +} + +.root :global(.scan-ai-decision-band.watch) { + border-color: rgba(245, 158, 11, 0.38); +} + +.root :global(.scan-ai-decision-main) { + min-width: 0; +} + +.root :global(.scan-ai-decision-band span) { + color: #4da3ff; + font-size: 12px; + font-weight: 900; +} + +.root :global(.scan-ai-decision-band strong) { + display: block; + margin-top: 5px; + color: #e6edf3; + font-size: 24px; + line-height: 1.2; +} + +.root :global(.scan-ai-decision-band p) { + margin: 8px 0 0; + color: #9fb2c7; + line-height: 1.55; +} + +.root :global(.scan-ai-decision-why) { + display: inline-flex; + max-width: 100%; + border: 1px solid rgba(77, 163, 255, 0.24); + border-radius: 12px; + background: rgba(77, 163, 255, 0.1); + color: #d8e7f8 !important; + font-weight: 900; + padding: 9px 11px; +} + +.root :global(.scan-ai-decision-reasons) { + display: grid; + gap: 8px; + margin-top: 14px; +} + +.root :global(.scan-ai-decision-reasons small) { + position: relative; + display: block; + padding-left: 16px; + color: #c9d7e8; + font-size: 12px; + font-weight: 760; + line-height: 1.45; +} + +.root :global(.scan-ai-decision-reasons small::before) { + position: absolute; + top: 0.58em; + left: 0; + width: 6px; + height: 6px; + border-radius: 999px; + background: #4da3ff; + content: ""; +} + +.root :global(.scan-ai-decision-risk) { + margin-top: 12px !important; + color: #fbbf24 !important; + font-size: 12px; + font-weight: 800; +} + +.root :global(.scan-ai-market-mobile-line) { + display: none; +} + +.root :global(.scan-ai-market-decision) { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 12px; + align-items: center; + margin-top: 14px; + border: 1px solid rgba(77, 163, 255, 0.18); + border-radius: 14px; + background: rgba(11, 18, 32, 0.44); + padding: 12px; +} + +.root :global(.scan-ai-market-decision.warm) { + border-color: rgba(34, 197, 94, 0.36); +} + +.root :global(.scan-ai-market-decision.cold) { + border-color: rgba(239, 68, 68, 0.36); +} + +.root :global(.scan-ai-market-decision.watch) { + border-color: rgba(245, 158, 11, 0.38); +} + +.root :global(.scan-ai-market-decision span) { + color: #9fb2c7; + font-size: 11px; + font-weight: 900; +} + +.root :global(.scan-ai-market-decision strong) { + margin-top: 3px; + color: #e6edf3; + font-size: 15px; +} + +.root :global(.scan-ai-market-decision p) { + margin-top: 5px; + font-size: 12px; +} + +.root :global(.scan-ai-market-decision-stats) { + display: grid; + grid-template-columns: repeat(3, minmax(82px, 1fr)); + gap: 8px; +} + +.root :global(.scan-ai-market-decision-stats small) { + display: grid; + gap: 3px; + border-radius: 10px; + background: rgba(17, 26, 46, 0.82); + color: #9fb2c7; + font-size: 11px; + font-weight: 800; + padding: 8px; +} + +.root :global(.scan-ai-market-decision-stats b) { + color: #e6edf3; + font-size: 13px; +} + +.root :global(.scan-ai-market-link) { + border: 1px solid rgba(77, 163, 255, 0.34); + border-radius: 10px; + background: rgba(77, 163, 255, 0.12); + color: #9ecbff; + font-size: 12px; + font-weight: 900; + padding: 9px 10px; + text-decoration: none; + white-space: nowrap; +} + +.root :global(.scan-ai-decision-metrics) { + min-width: 360px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.root :global(.scan-ai-decision-metrics span) { + border: 1px solid rgba(159, 178, 199, 0.14); + border-radius: 12px; + background: rgba(11, 18, 32, 0.5); + color: #9fb2c7; + padding: 12px; +} + +.root :global(.scan-ai-decision-metrics b) { + display: block; + margin-top: 4px; + color: #e6edf3; + font-size: 15px; +} + +.root :global(.scan-ai-city-analysis-grid) { + display: grid; + grid-template-columns: minmax(420px, 1.4fr) minmax(280px, 0.8fr); + gap: 14px; + margin-top: 14px; +} + +.root :global(.scan-ai-city-section) { + border: 1px solid rgba(159, 178, 199, 0.12); + border-radius: 16px; + background: rgba(11, 18, 32, 0.38); + padding: 16px; +} + +.root :global(.scan-ai-city-section-title) { + display: inline-flex; + align-items: center; + gap: 8px; + color: #4da3ff; + font-size: 13px; + font-weight: 900; + margin-bottom: 12px; +} + +.root :global(.scan-ai-city-ai-read) { + overflow: hidden; +} + +.root :global(.scan-ai-city-ai-read summary) { + cursor: pointer; + list-style: none; +} + +.root :global(.scan-ai-city-ai-read summary::-webkit-details-marker) { + display: none; +} + +.root :global(.scan-ai-city-ai-read summary::after) { + width: 18px; + height: 18px; + display: inline-grid; + place-items: center; + border-radius: 999px; + background: rgba(77, 163, 255, 0.12); + color: #9ecbff; + content: "⌄"; + font-size: 12px; + line-height: 1; + transition: transform 0.18s ease; +} + +.root :global(.scan-ai-city-ai-read[open] summary::after) { + transform: rotate(180deg); +} + +.root :global(.scan-ai-city-section-body) { + margin-top: 2px; +} + +.root :global(.scan-ai-city-section p) { + margin: 0 0 10px; + color: #9fb2c7; + font-size: 13px; + line-height: 1.6; +} + +.root :global(.scan-ai-weather-summary) { + color: #e6edf3; + font-weight: 850; +} + +.root :global(.scan-ai-weather-bullets) { + display: grid; + gap: 8px; + margin: 10px 0 0; + padding-left: 18px; + color: #9fb2c7; + font-size: 13px; + font-weight: 700; + line-height: 1.6; +} + +.root :global(.scan-ai-weather-bullets li::marker) { + color: #4da3ff; +} + +.root :global(.scan-ai-raw-metar) { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(77, 163, 255, 0.12); + color: #6b7a90; + font-family: + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + "Liberation Mono", + monospace; + font-size: 12px; +} + +.root :global(.scan-ai-city-chart) { + height: 260px; +} + +.root :global(.scan-ai-city-chart canvas) { + display: block; + width: 100% !important; + height: 100% !important; +} + +.root :global(.scan-ai-city-chart-placeholder) { + display: grid; + height: 100%; + place-items: center; + border: 1px dashed rgba(77, 163, 255, 0.22); + border-radius: 14px; + background: rgba(11, 18, 32, 0.35); + color: #7f96b6; + font-size: 12px; + font-weight: 800; +} + +.root :global(.scan-ai-city-chart-legend) { + display: flex; + gap: 14px; + margin-top: 10px; + color: #9fb2c7; + font-size: 12px; + font-weight: 800; +} + +.root :global(.scan-ai-city-chart-legend span) { + display: inline-flex; + align-items: center; + gap: 7px; +} + +.root :global(.scan-ai-city-chart-legend i) { + width: 18px; + height: 3px; + border-radius: 999px; + background: #4da3ff; +} + +.root :global(.scan-ai-city-chart-legend i.observation) { + width: 8px; + height: 8px; + background: #22c55e; +} + +.root :global(.scan-ai-city-section.models) { + margin-top: 14px; +} + +.root :global(.scan-ai-city-section.models .models-section) { + padding: 0; + border: 0; + background: transparent; +} + +.root :global(.scan-ai-city-section-head) { + display: flex; + justify-content: space-between; + gap: 14px; + align-items: flex-start; + margin-bottom: 12px; +} + +.root :global(.scan-ai-city-section.market) { + margin-top: 14px; +} + +.root :global(.scan-ai-market-buckets) { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(155px, 1fr)); + gap: 10px; +} + +.root :global(.scan-ai-market-bucket) { + display: grid; + gap: 5px; + border: 1px solid rgba(159, 178, 199, 0.12); + border-radius: 12px; + background: rgba(17, 26, 46, 0.9); + padding: 12px; +} + +.root :global(.scan-ai-market-bucket strong) { + color: #e6edf3; + font-size: 15px; +} + +.root :global(.scan-ai-market-bucket span), +.root :global(.scan-ai-city-muted), +.root :global(.scan-ai-city-loading) { + color: #9fb2c7; + font-size: 12px; + font-weight: 700; +} + +.root :global(.scan-ai-city-loading) { + padding: 28px; +} + +.root :global(.scan-ai-brief-grid) { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(260px, 0.75fr); + gap: 28px; +} + +.root :global(.scan-ai-brief-grid ul) { + display: grid; + gap: 8px; + margin: 9px 0 0; + padding: 0; + list-style: none; +} + +.root :global(.scan-ai-brief-grid li) { + position: relative; + padding-left: 16px; + color: #9FB2C7; + font-size: 13px; + font-weight: 760; + line-height: 1.48; +} + +.root :global(.scan-ai-brief-grid li::before) { + position: absolute; + top: 0.65em; + left: 0; + width: 5px; + height: 5px; + border-radius: 999px; + background: #4DA3FF; + box-shadow: 0 0 10px rgba(77, 163, 255, 0.34); + content: ""; +} + +.root :global(.scan-ai-brief-grid section:nth-child(2) li::before) { + background: #F59E0B; + box-shadow: 0 0 10px rgba(245, 158, 11, 0.35); +} + +.root :global(.scan-ai-airport-read) { + display: grid; + gap: 6px; + padding-top: 12px; + border-top: 1px solid rgba(159, 178, 199, 0.1); +} + +.root :global(.scan-ai-airport-read p) { + margin: 0; + color: #9FB2C7; + font-size: 12px; + font-weight: 800; + line-height: 1.5; +} diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index a07ec983..b434140e 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -19,6 +19,11 @@ import { import styles from "./Dashboard.module.css"; import detailChromeStyles from "./DetailPanelChrome.module.css"; import modalChromeStyles from "./ModalChrome.module.css"; +import scanTerminalCalendarStyles from "./ScanTerminalCalendar.module.css"; +import scanTerminalCardStyles from "./ScanTerminalCard.module.css"; +import scanTerminalLightThemeStyles from "./ScanTerminalLightTheme.module.css"; +import scanTerminalMobileStyles from "./ScanTerminalMobile.module.css"; +import scanTerminalOpportunityStyles from "./ScanTerminalOpportunity.module.css"; import scanTerminalStyles from "./ScanTerminal.module.css"; import { DetailPanel as CityDetailPanel } from "@/components/dashboard/DetailPanel"; import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal"; @@ -78,6 +83,18 @@ function ScanTerminalScreen() { const userLocalTime = useUserLocalClock(); const { setThemeMode, themeMode } = useScanTerminalTheme(); const lastMapSelectedCityRef = useRef(""); + const scanTerminalRootClassName = clsx( + styles.root, + scanTerminalStyles.root, + scanTerminalOpportunityStyles.root, + scanTerminalCardStyles.root, + scanTerminalCalendarStyles.root, + scanTerminalMobileStyles.root, + scanTerminalLightThemeStyles.root, + detailChromeStyles.root, + modalChromeStyles.root, + themeMode === "light" && "light", + ); const timeSortedRows = useMemo( () => sortRowsByUserTime(terminalData?.rows || []), @@ -373,7 +390,7 @@ function ScanTerminalScreen() { if (store.proAccess.loading) { return ( -
+
@@ -393,7 +410,7 @@ function ScanTerminalScreen() { } return ( -
+
diff --git a/frontend/components/dashboard/ScanTerminalLightTheme.module.css b/frontend/components/dashboard/ScanTerminalLightTheme.module.css new file mode 100644 index 00000000..ab7bd157 --- /dev/null +++ b/frontend/components/dashboard/ScanTerminalLightTheme.module.css @@ -0,0 +1,841 @@ +/* Scan terminal light theme overrides. Keep imported after other scan CSS modules. */ + +.root :global(.scan-terminal.light) { + background: + radial-gradient(circle at top, rgba(59, 130, 246, 0.1), transparent 34%), + linear-gradient(180deg, #F7F9FC 0%, #EEF2F7 100%); + color: #0F172A; +} + +.root :global(.scan-terminal.light .scan-filter-panel), +.root :global(.scan-terminal.light .scan-data-grid), +.root :global(.scan-terminal.light .scan-detail-panel), +.root :global(.scan-terminal.light > .detail-panel.scan-city-detail-rail) { + border-color: #E2E8F0; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.96)); + box-shadow: 0 16px 34px rgba(40, 70, 110, 0.12); +} + +.root :global(.scan-terminal.light .scan-sidebar-brand-name), +.root :global(.scan-terminal.light .scan-topbar-tab.active), +.root :global(.scan-terminal.light .scan-hero h1), +.root :global(.scan-terminal.light .scan-topbar-title strong), +.root :global(.scan-terminal.light .scan-city-name), +.root :global(.scan-terminal.light .scan-opportunity-city strong), +.root :global(.scan-terminal.light .panel-title-area h2), +.root :global(.scan-terminal.light .detail-summary-temp), +.root :global(.scan-terminal.light .scan-detail-city-name), +.root :global(.scan-terminal.light .scan-empty-title), +.root :global(.scan-terminal.light .scan-detail-section-title), +.root :global(.scan-terminal.light .scan-detail-volume-big), +.root :global(.scan-terminal.light .scan-kv strong), +.root :global(.scan-terminal.light .scan-time-main), +.root :global(.scan-terminal.light .scan-calendar-date), +.root :global(.scan-terminal.light .scan-summary-value), +.root :global(.scan-terminal.light .scan-distribution-card strong), +.root :global(.scan-terminal.light .scan-table-header), +.root :global(.scan-terminal.light .scan-list-tabs button.active) { + color: #0F172A; +} + +.root :global(.scan-terminal.light .scan-filter-heading), +.root :global(.scan-terminal.light .scan-topbar-actions), +.root :global(.scan-terminal.light .scan-topbar-time), +.root :global(.scan-terminal.light .scan-hero p), +.root :global(.scan-terminal.light .scan-topbar-title span), +.root :global(.scan-terminal.light .scan-city-sub), +.root :global(.scan-terminal.light .scan-opportunity-phase), +.root :global(.scan-terminal.light .scan-opportunity-phase em), +.root :global(.scan-terminal.light .panel-overline), +.root :global(.scan-terminal.light .detail-summary-supporting), +.root :global(.scan-terminal.light .detail-value-muted), +.root :global(.scan-terminal.light .detail-empty-state), +.root :global(.scan-terminal.light .scan-time-remaining), +.root :global(.scan-terminal.light .scan-detail-city-sub), +.root :global(.scan-terminal.light .scan-detail-volume-caption), +.root :global(.scan-terminal.light .scan-empty-copy), +.root :global(.scan-terminal.light .scan-loading-copy-block span), +.root :global(.scan-terminal.light .scan-kv span:first-child), +.root :global(.scan-terminal.light .scan-chart-label), +.root :global(.scan-terminal.light .scan-trade-sub), +.root :global(.scan-terminal.light .scan-trade-note) { + color: #475569; +} + +.root :global(.scan-terminal.light .scan-loading-copy-block strong) { + color: #0f172a; +} + +.root :global(.scan-terminal.light .scan-loading-signal) { + border-color: #dbeafe; + background: + radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.12), transparent 34%), + linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(241, 245, 249, 0.9)); + box-shadow: 0 16px 34px rgba(40, 70, 110, 0.1); +} + +.root :global(.scan-terminal.light .scan-loading-signal.compact) { + background: + radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.1), transparent 34%), + rgba(255, 255, 255, 0.78); +} + +.root :global(.scan-terminal.light .scan-loading-node) { + background: #ffffff; + border-color: rgba(148, 163, 184, 0.22); +} + +.root :global(.scan-terminal.light .scan-loading-node.hot) { + background: linear-gradient(135deg, #f97316, #fde047); +} + +.root :global(.scan-terminal.light .scan-loading-node.market) { + background: linear-gradient(135deg, #38bdf8, #2563eb); +} + +.root :global(.scan-terminal.light .scan-loading-node.action) { + background: linear-gradient(135deg, #16a34a, #22c55e); +} + +.root :global(.scan-terminal.light .scan-list-tabs button), +.root :global(.scan-terminal.light .scan-mode-tab-sub), +.root :global(.scan-terminal.light .scan-opportunity-models em), +.root :global(.scan-terminal.light .scan-opportunity-stat small), +.root :global(.scan-terminal.light .panel-meta-chip), +.root :global(.scan-terminal.light .panel-weather-chip), +.root :global(.scan-terminal.light .scan-distribution-card), +.root :global(.scan-terminal.light .scan-chart-legend), +.root :global(.scan-terminal.light .scan-kpi-note) { + color: #94A3B8; +} + +.root :global(.scan-terminal.light .scan-distribution-line em) { + color: #243b5a; +} + +.root :global(.scan-terminal.light .scan-distribution-line b) { + color: #6f86a4; +} + +.root :global(.scan-terminal.light .scan-mode-tab), +.root :global(.scan-terminal.light .scan-table-shell), +.root :global(.scan-terminal.light .scan-table-header), +.root :global(.scan-terminal.light .scan-kpi-card), +.root :global(.scan-terminal.light .scan-calendar-group), +.root :global(.scan-terminal.light .scan-calendar-card), +.root :global(.scan-terminal.light .scan-summary-card), +.root :global(.scan-terminal.light .scan-settings-card), +.root :global(.scan-terminal.light .scan-distribution-card), +.root :global(.scan-terminal.light .scan-opportunity-group), +.root :global(.scan-terminal.light .detail-summary-shell), +.root :global(.scan-terminal.light .detail-structured-section), +.root :global(.scan-terminal.light .detail-card), +.root :global(.scan-terminal.light .scan-trade-card) { + border-color: #E2E8F0; + background: #FFFFFF; +} + +.root :global(.scan-terminal.light .scan-mode-tab.active) { + color: #1D4ED8; + background: #DBEAFE; + border-color: rgba(59, 130, 246, 0.34); + box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.16); +} + +.root :global(.scan-terminal.light .scan-mode-tab.active .scan-mode-tab-label) { + color: #1D4ED8; +} + +.root :global(.scan-terminal.light .scan-mode-tab.active .scan-mode-tab-sub) { + color: #475569; +} + +.root :global(.scan-terminal.light .scan-calendar-subtitle), +.root :global(.scan-terminal.light .scan-calendar-action span), +.root :global(.scan-terminal.light .scan-calendar-countdown small) { + color: #647a98; +} + +.root :global(.scan-terminal.light .scan-calendar-action b) { + color: #122033; +} + +.root :global(.scan-terminal.light .scan-calendar-reason) { + color: #243b5a; +} + +.root :global(.scan-terminal.light .scan-calendar-countdown) { + border-color: rgba(10, 160, 100, 0.18); + background: rgba(220, 252, 239, 0.68); + color: #087b55; +} + +.root :global(.scan-terminal.light .scan-calendar-card.peak-active) { + border-color: rgba(10, 160, 100, 0.34); + background: linear-gradient(180deg, rgba(220, 252, 239, 0.84), rgba(255, 255, 255, 0.86)); +} + +.root :global(.scan-terminal.light .scan-mode-tab.active .scan-mode-icon) { + background: rgba(16, 185, 129, 0.16); + color: #069668; +} + +.root :global(.scan-terminal.light .scan-table-row.selected) { + background: linear-gradient(180deg, rgba(228, 252, 242, 0.92), rgba(241, 250, 247, 0.96)); + border-color: rgba(10, 160, 100, 0.34); + box-shadow: inset 0 0 0 1px rgba(10, 160, 100, 0.18); +} + +.root :global(.scan-terminal.light .scan-table-row) { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(247, 250, 254, 0.9)); + border-bottom-color: rgba(35, 72, 118, 0.08); +} + +.root :global(.scan-terminal.light .scan-table-row:hover) { + background: linear-gradient(180deg, rgba(248, 252, 255, 0.96), rgba(238, 247, 255, 0.96)); +} + +.root :global(.scan-terminal.light .scan-kpi-card) { + box-shadow: 0 10px 24px rgba(40, 70, 110, 0.08); +} + +.root :global(.scan-terminal.light .scan-kpi-value), +.root :global(.scan-terminal.light .scan-opportunity-phase > span) { + color: #14253a; +} + +.root :global(.scan-terminal.light .scan-opportunity-group-head) { + background: rgba(246, 250, 255, 0.78); + border-bottom-color: rgba(35, 72, 118, 0.1); +} + +.root :global(.scan-terminal.light .scan-opportunity-item) { + background: rgba(255, 255, 255, 0.62); + color: #405977; + border-bottom-color: rgba(35, 72, 118, 0.08); + border-color: rgba(35, 72, 118, 0.1); +} + +.root :global(.scan-terminal.light .scan-opportunity-item:hover) { + background: rgba(230, 242, 255, 0.76); +} + +.root :global(.scan-terminal.light .scan-opportunity-item.selected) { + background: linear-gradient(180deg, rgba(222, 250, 238, 0.9), rgba(241, 250, 247, 0.96)); + border-color: rgba(10, 160, 100, 0.34); +} + +.root :global(.scan-terminal.light .scan-opportunity-group.selected) { + border-color: rgba(10, 160, 100, 0.34); + box-shadow: inset 0 0 0 1px rgba(10, 160, 100, 0.18); +} + +.root :global(.scan-terminal.light .scan-opportunity-models span) { + border-color: rgba(35, 72, 118, 0.1); + background: rgba(255, 255, 255, 0.68); +} + +.root :global(.scan-terminal.light .scan-opportunity-stat) { + border-color: rgba(35, 72, 118, 0.1); + background: rgba(248, 252, 255, 0.76); +} + +.root :global(.scan-terminal.light .scan-opportunity-models b), +.root :global(.scan-terminal.light .scan-opportunity-stat b) { + color: #14253a; +} + +.root :global(.scan-terminal.light .scan-opportunity-branch::before), +.root :global(.scan-terminal.light .scan-opportunity-branch i) { + background: rgba(56, 86, 126, 0.24); +} + +.root :global(.scan-terminal.light .panel-header), +.root :global(.scan-terminal.light .panel-body section) { + border-color: rgba(35, 72, 118, 0.1); +} + +.root :global(.scan-terminal.light .panel-action-button-secondary), +.root :global(.scan-terminal.light .panel-action-button-ghost) { + border-color: rgba(35, 72, 118, 0.14); + background: rgba(255, 255, 255, 0.76); +} + +.root :global(.scan-terminal.light .detail-summary-shell-live) { + background: linear-gradient(180deg, rgba(224, 247, 255, 0.82), rgba(241, 248, 255, 0.92)); +} + +.root :global(.scan-terminal.light .scan-locale-switch), +.root :global(.scan-terminal.light .scan-ghost-button), +.root :global(.scan-terminal.light .scan-theme-button), +.root :global(.scan-terminal.light .scan-sort-pill), +.root :global(.scan-terminal.light .scan-icon-pill), +.root :global(.scan-terminal.light .scan-account-button), +.root :global(.scan-terminal.light .scan-detail-action-button) { + border-color: rgba(35, 72, 118, 0.14); + background: rgba(255, 255, 255, 0.78); + color: #1f3654; +} + +.root :global(.scan-terminal.light .scan-primary-button) { + color: #FFFFFF; + border-color: rgba(59, 130, 246, 0.34); + background: linear-gradient(180deg, #3B82F6, #2563EB); +} + +.root :global(.scan-terminal.light .scan-ai-button) { + color: #1D4ED8; + border-color: rgba(59, 130, 246, 0.28); + background: #DBEAFE; +} + +.root :global(.scan-terminal.light .scan-opportunity-ai) { + border-color: rgba(35, 72, 118, 0.1); + background: rgba(248, 252, 255, 0.76); +} + +.root :global(.scan-terminal.light .scan-opportunity-ai small) { + color: #647a98; +} + +.root :global(.scan-terminal.light .scan-opportunity-strength) { + border-color: rgba(35, 72, 118, 0.12); + background: rgba(248, 252, 255, 0.78); +} + +.root :global(.scan-terminal.light .scan-opportunity-expand) { + color: #1d4ed8; + border-color: rgba(37, 99, 235, 0.18); + background: rgba(239, 246, 255, 0.84); +} + +.root :global(.scan-terminal.light .scan-v4-analysis), +.root :global(.scan-terminal.light .scan-v4-analysis section), +.root :global(.scan-terminal.light .scan-v4-evidence > div > span), +.root :global(.scan-terminal.light .scan-v4-model-sources span) { + border-color: rgba(35, 72, 118, 0.12); + background: rgba(255, 255, 255, 0.76); +} + +.root :global(.scan-terminal.light .scan-v4-analysis strong) { + color: #057a6b; +} + +.root :global(.scan-terminal.light .scan-v4-analysis p), +.root :global(.scan-terminal.light .scan-v4-analysis ul), +.root :global(.scan-terminal.light .scan-v4-evidence > div > span) { + color: #6B7A90; +} + +.root :global(.scan-terminal.light .scan-v4-model-sources em) { + color: #6B7A90; +} + +.root :global(.scan-terminal.light .scan-v4-model-sources b) { + color: #0f172a; +} + +.root :global(.scan-terminal.light .scan-forecast-city-card), +.root :global(.scan-terminal.light .scan-forecast-row), +.root :global(.scan-terminal.light .scan-ai-analysis) { + border-color: #E2E8F0; + background: #FFFFFF; +} + +.root :global(.scan-terminal.light .scan-forecast-city-head) { + background: #EEF2F7; +} + +.root :global(.scan-terminal.light .scan-forecast-city-card.selected), +.root :global(.scan-terminal.light .scan-forecast-city-card:has(.scan-forecast-row.selected)) { + border-color: rgba(59, 130, 246, 0.38); + box-shadow: + inset 0 0 0 1px rgba(59, 130, 246, 0.12), + 0 16px 34px rgba(40, 70, 110, 0.12); +} + +.root :global(.scan-terminal.light .scan-forecast-row.selected), +.root :global(.scan-terminal.light .scan-forecast-row.expanded) { + border-color: rgba(59, 130, 246, 0.38); + background: + linear-gradient(180deg, rgba(219, 234, 254, 0.62), rgba(255, 255, 255, 0.98)), + #FFFFFF; + box-shadow: inset 4px 0 0 #3B82F6; +} + +.root :global(.scan-terminal.light .scan-forecast-city-title strong), +.root :global(.scan-terminal.light .scan-forecast-city-read > b:not(.scan-phase-badge)), +.root :global(.scan-terminal.light .scan-forecast-bucket strong), +.root :global(.scan-terminal.light .scan-forecast-signals b), +.root :global(.scan-terminal.light .scan-ai-temperature-line b) { + color: #0F172A; +} + +.root :global(.scan-terminal.light .scan-forecast-city-chips span), +.root :global(.scan-terminal.light .scan-forecast-signals span), +.root :global(.scan-terminal.light .scan-ai-evidence-line span) { + border-color: #E2E8F0; + background: #EEF2F7; +} + +.root :global(.scan-terminal.light .scan-forecast-city-chips span), +.root :global(.scan-terminal.light .scan-forecast-city-read span), +.root :global(.scan-terminal.light .scan-forecast-bucket small), +.root :global(.scan-terminal.light .scan-forecast-ai-line small), +.root :global(.scan-terminal.light .scan-ai-brief-grid li), +.root :global(.scan-terminal.light .scan-ai-airport-read p) { + color: #475569; +} + +.root :global(.scan-terminal.light .scan-ai-analysis-head p) { + color: #0F172A; +} + +.root :global(.scan-terminal.light .scan-ai-analysis-head small), +.root :global(.scan-terminal.light .scan-ai-evidence-line b) { + color: #475569; +} + +.root :global(.scan-terminal.light .scan-ai-workspace) { + background: #f7f9fc; + border-color: #e2e8f0; +} + +.root :global(.scan-terminal.light .scan-opportunity-overview) { + background: #f7f9fc; + border-color: #e2e8f0; +} + +.root :global(.scan-terminal.light .scan-opportunity-hero > div:first-child), +.root :global(.scan-terminal.light .scan-opportunity-summary span), +.root :global(.scan-terminal.light .scan-opportunity-lane), +.root :global(.scan-terminal.light .scan-opportunity-decision-card), +.root :global(.scan-terminal.light .scan-opportunity-decision-primary span) { + background: #ffffff; + border-color: #e2e8f0; +} + +.root :global(.scan-terminal.light .scan-opportunity-hero strong), +.root :global(.scan-terminal.light .scan-opportunity-summary b), +.root :global(.scan-terminal.light .scan-opportunity-lane-head strong), +.root :global(.scan-terminal.light .scan-opportunity-decision-head strong), +.root :global(.scan-terminal.light .scan-opportunity-decision-primary b) { + color: #0f172a; +} + +.root :global(.scan-terminal.light .scan-opportunity-hero p), +.root :global(.scan-terminal.light .scan-opportunity-summary span), +.root :global(.scan-terminal.light .scan-opportunity-lane-head p), +.root :global(.scan-terminal.light .scan-opportunity-decision-card p), +.root :global(.scan-terminal.light .scan-opportunity-decision-head span), +.root :global(.scan-terminal.light .scan-opportunity-decision-primary span), +.root :global(.scan-terminal.light .scan-opportunity-decision-foot small) { + color: #64748b; +} + +.root :global(.scan-terminal.light .scan-ai-city-card), +.root :global(.scan-terminal.light .scan-ai-city-hero) { + background: #ffffff; + border-color: #e2e8f0; +} + +.root :global(.scan-terminal.light .scan-ai-city-section), +.root :global(.scan-terminal.light .scan-ai-decision-band), +.root :global(.scan-terminal.light .scan-ai-market-decision), +.root :global(.scan-terminal.light .scan-ai-market-decision-stats small), +.root :global(.scan-terminal.light .scan-ai-decision-metrics span), +.root :global(.scan-terminal.light .scan-ai-market-bucket), +.root :global(.scan-terminal.light .scan-ai-city-pills span), +.root :global(.scan-terminal.light .scan-ai-city-freshness span) { + background: #eef2f7; + border-color: #e2e8f0; +} + +.root :global(.scan-terminal.light .scan-ai-workspace-head strong), +.root :global(.scan-terminal.light .scan-ai-city-hero h3), +.root :global(.scan-terminal.light .scan-ai-city-hero-side > strong), +.root :global(.scan-terminal.light .scan-ai-decision-band strong), +.root :global(.scan-terminal.light .scan-ai-decision-metrics b), +.root :global(.scan-terminal.light .scan-ai-market-bucket strong), +.root :global(.scan-terminal.light .scan-ai-weather-summary), +.root :global(.scan-terminal.light .scan-ai-decision-reasons small), +.root :global(.scan-terminal.light .scan-ai-market-decision strong), +.root :global(.scan-terminal.light .scan-ai-market-decision-stats b) { + color: #0f172a; +} + +.root :global(.scan-terminal.light .scan-ai-workspace-head p), +.root :global(.scan-terminal.light .scan-ai-city-section p), +.root :global(.scan-terminal.light .scan-ai-decision-band p), +.root :global(.scan-terminal.light .scan-ai-city-pills span), +.root :global(.scan-terminal.light .scan-ai-city-freshness), +.root :global(.scan-terminal.light .scan-ai-decision-metrics span), +.root :global(.scan-terminal.light .scan-ai-market-bucket span), +.root :global(.scan-terminal.light .scan-ai-market-decision span), +.root :global(.scan-terminal.light .scan-ai-market-decision p), +.root :global(.scan-terminal.light .scan-ai-market-decision-stats small), +.root :global(.scan-terminal.light .scan-ai-city-muted), +.root :global(.scan-terminal.light .scan-ai-city-loading), +.root :global(.scan-terminal.light .scan-ai-city-chart-legend), +.root :global(.scan-terminal.light .scan-ai-weather-bullets) { + color: #475569; +} + +.root :global(.scan-terminal.light .scan-ai-raw-metar) { + border-top-color: #e2e8f0; + color: #64748b; +} + +.root :global(.scan-terminal.light .scan-ai-city-chart-placeholder) { + border-color: #cbd5e1; + background: #f8fafc; + color: #64748b; +} + +.root :global(.scan-terminal.light .scan-ai-log-panel) { + border-color: rgba(35, 72, 118, 0.12); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgba(246, 250, 255, 0.9)); +} + +.root :global(.scan-terminal.light .scan-ai-log-panel.compact) { + background: rgba(255, 255, 255, 0.82); +} + +.root :global(.scan-terminal.light .scan-ai-log-panel.compact .scan-ai-log-summary span) { + color: #334155; +} + +.root :global(.scan-terminal.light .scan-ai-log-panel.compact .scan-ai-log-summary strong) { + color: #2563eb; +} + +.root :global(.scan-terminal.light .scan-ai-log-head strong), +.root :global(.scan-terminal.light .scan-ai-log-item b) { + color: #122033; +} + +.root :global(.scan-terminal.light .scan-ai-log-head span), +.root :global(.scan-terminal.light .scan-ai-log-time), +.root :global(.scan-terminal.light .scan-ai-log-empty) { + color: #647a98; +} + +.root :global(.scan-terminal.light .scan-ai-log-item) { + border-color: rgba(35, 72, 118, 0.12); + background: rgba(255, 255, 255, 0.72); +} + +.root :global(.scan-terminal.light .scan-ai-log-item small) { + color: #58708f; +} + +.root :global(.scan-terminal.light .scan-ai-log-item.info) { + border-color: rgba(14, 165, 233, 0.24); +} + +.root :global(.scan-terminal.light .scan-ai-log-item.success) { + border-color: rgba(10, 160, 100, 0.28); +} + +.root :global(.scan-terminal.light .scan-ai-log-item.warning) { + border-color: rgba(217, 119, 6, 0.28); +} + +.root :global(.scan-terminal.light .scan-ai-log-item.error) { + border-color: rgba(220, 38, 38, 0.28); +} + +.root :global(.scan-terminal.light .scan-ai-summary-card), +.root :global(.scan-terminal.light .scan-ai-city-card) { + background: rgba(255, 255, 255, 0.92); + border-color: rgba(148, 163, 184, 0.28); +} + +.root :global(.scan-terminal.light .scan-ai-summary-card strong), +.root :global(.scan-terminal.light .scan-ai-city-head strong), +.root :global(.scan-terminal.light .scan-ai-contract b) { + color: #0f172a; +} + +.root :global(.scan-terminal.light .scan-ai-summary-card p), +.root :global(.scan-terminal.light .scan-ai-city-head p), +.root :global(.scan-terminal.light .scan-ai-contract p), +.root :global(.scan-terminal.light .scan-ai-contract small) { + color: #6B7A90; +} + +.root :global(.scan-terminal.light .scan-ai-city-head) { + background: #f8fafc; +} + +.root :global(.scan-terminal.light .scan-ai-cluster-note) { + color: #334155; + background: #f8fafc; + border-color: rgba(148, 163, 184, 0.26); +} + +.root :global(.scan-terminal.light .scan-ai-contract) { + border-top-color: rgba(148, 163, 184, 0.2); +} + +.root :global(.scan-terminal.light .scan-ai-inline-button) { + color: #064e3b; + background: #d1fae5; + border-color: rgba(16, 185, 129, 0.28); +} + +.root :global(.scan-terminal.light .scan-cta-ghost), +.root :global(.scan-terminal.light .scan-detail-analysis-button) { + border-color: rgba(7, 160, 100, 0.24); + background: rgba(11, 188, 116, 0.12); + color: #057a4d; +} + +.root :global(.scan-terminal.light .scan-score-ring::after) { + background: #f5f9fe; +} + +.root :global(.scan-terminal.light .scan-score-ring span) { + color: #122033; +} + +.root :global(.scan-terminal.light .scan-trade-card p) { + color: #405977; +} + +.root :global(.scan-terminal.light .scan-map-shell) { + background: + radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.12), transparent 34%), + linear-gradient(180deg, #f8fbff, #eaf3ff); + border-color: rgba(37, 99, 235, 0.16); + box-shadow: 0 16px 34px rgba(40, 70, 110, 0.12); +} + +.root :global(.scan-terminal.light .scan-map-shell .map), +.root :global(.scan-terminal.light .scan-map-shell .leaflet-container) { + background: #eaf3ff !important; +} + +.root :global(.scan-terminal.light .scan-map-shell .leaflet-tile) { + filter: saturate(1.06) contrast(0.98) brightness(1.04) !important; +} + +.root :global(.scan-terminal.light .scan-map-caption) { + color: #475569; +} + +.root :global(.scan-terminal.light .scan-list-tabs button), +.root :global(.scan-terminal.light .scan-mode-tab-sub), +.root :global(.scan-terminal.light .scan-opportunity-models em), +.root :global(.scan-terminal.light .scan-opportunity-stat small), +.root :global(.scan-terminal.light .panel-meta-chip), +.root :global(.scan-terminal.light .panel-weather-chip), +.root :global(.scan-terminal.light .scan-distribution-card), +.root :global(.scan-terminal.light .scan-chart-legend), +.root :global(.scan-terminal.light .scan-kpi-note), +.root :global(.scan-terminal.light .scan-calendar-subtitle), +.root :global(.scan-terminal.light .scan-calendar-action span), +.root :global(.scan-terminal.light .scan-calendar-countdown small), +.root :global(.scan-terminal.light .scan-opportunity-ai small), +.root :global(.scan-terminal.light .scan-ai-log-head span), +.root :global(.scan-terminal.light .scan-ai-log-time), +.root :global(.scan-terminal.light .scan-ai-log-empty), +.root :global(.scan-terminal.light .scan-ai-log-item small), +.root :global(.scan-terminal.light .scan-ai-summary-card p), +.root :global(.scan-terminal.light .scan-ai-city-head p), +.root :global(.scan-terminal.light .scan-ai-contract p), +.root :global(.scan-terminal.light .scan-ai-contract small), +.root :global(.scan-terminal.light .scan-v4-analysis p), +.root :global(.scan-terminal.light .scan-v4-analysis ul), +.root :global(.scan-terminal.light .scan-v4-evidence > div > span), +.root :global(.scan-terminal.light .scan-v4-model-sources em), +.root :global(.scan-terminal.light .scan-ai-raw-metar), +.root :global(.scan-terminal.light .scan-opportunity-hero p), +.root :global(.scan-terminal.light .scan-opportunity-summary span), +.root :global(.scan-terminal.light .scan-opportunity-lane-head p), +.root :global(.scan-terminal.light .scan-opportunity-decision-card p), +.root :global(.scan-terminal.light .scan-opportunity-decision-head span), +.root :global(.scan-terminal.light .scan-opportunity-decision-primary span), +.root :global(.scan-terminal.light .scan-opportunity-decision-foot small) { + color: #475569; +} + +/* Light-mode hard overrides for the scan terminal. Keep this block last so the + decision workspace cannot inherit the dark map/card palette from base rules. */ +.root:global(.light) { + --bg-primary: #eef7ff; + --bg-secondary: #e0f2fe; + --bg-card: #ffffff; + --bg-glass: rgba(255, 255, 255, 0.92); + --border-glass: #cfe8ff; + --border-subtle: rgba(125, 171, 214, 0.34); + --text-primary: #0f172a; + --text-secondary: #334155; + --text-muted: #475569; +} + +.root:global(.light) :global(.scan-terminal.light) { + background: + radial-gradient(circle at 18% 12%, rgba(59, 130, 246, 0.12), transparent 30%), + linear-gradient(180deg, #eef7ff 0%, #e8f4ff 48%, #f8fbff 100%) !important; + color: #0f172a !important; +} + +:global(html.light) .root :global(.map), +.root:global(.light) :global(.map), +.root:global(.light) :global(.scan-terminal.light .scan-map-shell .map), +.root:global(.light) :global(.scan-terminal.light .scan-map-shell .leaflet-container), +.root:global(.light) :global(.leaflet-container), +.root:global(.light) :global(.leaflet-pane), +.root:global(.light) :global(.leaflet-map-pane), +.root:global(.light) :global(.leaflet-tile-pane) { + background: #eef7ff !important; +} + +:global(html.light) .root :global(.map), +.root:global(.light) :global(.scan-terminal.light .scan-map-shell) { + border-color: rgba(37, 99, 235, 0.18) !important; + box-shadow: + 0 18px 40px rgba(61, 100, 145, 0.14), + inset 0 0 0 1px rgba(255, 255, 255, 0.72) !important; +} + +:global(html.light) .root :global(.map .leaflet-tile), +.root:global(.light) :global(.map .leaflet-tile), +.root:global(.light) :global(.scan-terminal.light .scan-map-shell .leaflet-tile) { + filter: none !important; + opacity: 1 !important; +} + +.root:global(.light) :global(.scan-city-detail-rail), +.root:global(.light) :global(.scan-terminal.light > .detail-panel.scan-city-detail-rail) { + background: + linear-gradient(180deg, rgba(238, 247, 255, 0.98), rgba(255, 255, 255, 0.96)) !important; + border-color: rgba(37, 99, 235, 0.16) !important; + color: #0f172a !important; +} + +.root:global(.light) :global(.scan-city-detail-rail .panel-header), +.root:global(.light) :global(.scan-city-detail-rail .detail-structured-section), +.root:global(.light) :global(.scan-city-detail-rail .detail-summary-shell), +.root:global(.light) :global(.scan-city-detail-rail .detail-card) { + background: rgba(255, 255, 255, 0.74) !important; + border-color: rgba(125, 171, 214, 0.28) !important; +} + +.root:global(.light) :global(.scan-city-detail-rail h2), +.root:global(.light) :global(.scan-city-detail-rail h3), +.root:global(.light) :global(.scan-city-detail-rail strong), +.root:global(.light) :global(.scan-city-detail-rail .detail-value), +.root:global(.light) :global(.scan-city-detail-rail .detail-summary-temp), +.root:global(.light) :global(.scan-city-detail-rail .detail-section-head h3), +.root:global(.light) :global(.scan-city-detail-rail .panel-title-area h2) { + color: #0f172a !important; +} + +.root:global(.light) :global(.scan-city-detail-rail p), +.root:global(.light) :global(.scan-city-detail-rail li), +.root:global(.light) :global(.scan-city-detail-rail small), +.root:global(.light) :global(.scan-city-detail-rail .panel-overline), +.root:global(.light) :global(.scan-city-detail-rail .panel-loading-hint), +.root:global(.light) :global(.scan-city-detail-rail .panel-meta-chip), +.root:global(.light) :global(.scan-city-detail-rail .detail-section-kicker), +.root:global(.light) :global(.scan-city-detail-rail .detail-label), +.root:global(.light) :global(.scan-city-detail-rail .detail-value-muted), +.root:global(.light) :global(.scan-city-detail-rail .detail-source-note), +.root:global(.light) :global(.scan-city-detail-rail .detail-source-kind), +.root:global(.light) :global(.scan-city-detail-rail .detail-mini-meta), +.root:global(.light) :global(.scan-city-detail-rail .detail-summary-supporting), +.root:global(.light) :global(.scan-city-detail-rail .scan-detail-city-sub), +.root:global(.light) :global(.scan-city-detail-rail .scan-detail-volume-caption), +.root:global(.light) :global(.scan-city-detail-rail .scan-detail-score-label), +.root:global(.light) :global(.scan-city-detail-rail .scan-detail-empty) { + color: #334155 !important; +} + +.root:global(.light) :global(.scan-city-detail-rail .panel-meta-chip), +.root:global(.light) :global(.scan-city-detail-rail .panel-weather-chip), +.root:global(.light) :global(.scan-city-detail-rail .detail-source-link), +.root:global(.light) :global(.scan-city-detail-rail .panel-action-button-secondary), +.root:global(.light) :global(.scan-city-detail-rail .panel-action-button-ghost) { + background: #eef7ff !important; + border-color: rgba(37, 99, 235, 0.18) !important; + color: #1f3654 !important; +} + +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card), +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-section), +.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-band), +.root:global(.light) :global(.scan-terminal.light .scan-ai-market-decision), +.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-metrics span), +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-pills span), +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-freshness span), +.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-metrics span), +.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-reason) { + background: #eef7ff !important; + border-color: rgba(37, 99, 235, 0.16) !important; +} + +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card p), +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card li), +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card small), +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card span:not(.scan-ai-city-kicker)) { + color: #334155 !important; +} + +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.green) { + color: #047857 !important; +} + +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.blue) { + color: #1d4ed8 !important; +} + +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.amber) { + color: #b45309 !important; +} + +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.red) { + color: #b91c1c !important; +} + +.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement) { + background: + radial-gradient(circle at top left, rgba(37, 99, 235, 0.12), transparent 36%), + #ffffff !important; + border-color: rgba(37, 99, 235, 0.16) !important; + box-shadow: 0 16px 36px rgba(61, 100, 145, 0.12) !important; +} + +.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement-copy strong), +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-mobile-priority b), +.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-metrics b), +.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-reason), +.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-head h3), +.root:global(.light) :global(.scan-terminal.light .scan-ai-market-mobile-line b) { + color: #0f172a !important; +} + +.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement-copy p), +.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement li), +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-mobile-priority small), +.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-metrics small), +.root:global(.light) :global(.scan-terminal.light .scan-ai-market-mobile-line span) { + color: #334155 !important; +} + +.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement-copy span) { + color: #047857 !important; +} + +.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement li), +.root:global(.light) :global(.scan-terminal.light .scan-ai-city-mobile-priority span), +.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-why), +.root:global(.light) :global(.scan-terminal.light .scan-ai-market-mobile-line) { + background: #eef7ff !important; + border-color: rgba(37, 99, 235, 0.16) !important; +} + +.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-why) { + color: #1d4ed8 !important; +} diff --git a/frontend/components/dashboard/ScanTerminalMobile.module.css b/frontend/components/dashboard/ScanTerminalMobile.module.css new file mode 100644 index 00000000..b7ae2cea --- /dev/null +++ b/frontend/components/dashboard/ScanTerminalMobile.module.css @@ -0,0 +1,413 @@ +/* Scan terminal mobile action-card layout and responsive overrides. */ + +.root :global(.scan-mobile-decision-card) { + display: grid; + gap: 12px; + padding-bottom: 14px; +} + +.root :global(.scan-mobile-decision-head) { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 16px 16px 0; +} + +.root :global(.scan-mobile-decision-head h3) { + margin: 6px 0 0; + color: #e6edf3; + font-size: 24px; + line-height: 1.08; +} + +.root :global(.scan-mobile-decision-metrics) { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + padding: 0 14px; +} + +.root :global(.scan-mobile-decision-metrics span) { + display: grid; + gap: 4px; + border: 1px solid rgba(77, 163, 255, 0.18); + border-radius: 12px; + background: rgba(77, 163, 255, 0.09); + padding: 9px; +} + +.root :global(.scan-mobile-decision-metrics small) { + color: #9fb2c7; + font-size: 10px; + font-weight: 900; +} + +.root :global(.scan-mobile-decision-metrics b) { + color: #f3f8ff; + font-size: 13px; + line-height: 1.15; +} + +.root :global(.scan-mobile-decision-reason) { + margin: 0 14px; + border: 1px solid rgba(77, 163, 255, 0.24); + border-radius: 13px; + background: rgba(77, 163, 255, 0.1); + color: #d8e7f8; + font-size: 14px; + font-weight: 900; + line-height: 1.45; + padding: 11px 12px; +} + +.root :global(.scan-mobile-decision-card .scan-ai-city-status-tags), +.root :global(.scan-mobile-decision-card .scan-ai-city-freshness), +.root :global(.scan-mobile-decision-card .scan-ai-market-mobile-line), +.root :global(.scan-mobile-decision-folds) { + margin-right: 14px; + margin-left: 14px; +} + +.root :global(.scan-mobile-decision-card .scan-ai-city-freshness) { + grid-template-columns: 1fr; + max-width: none; + margin-top: 0; +} + +.root :global(.scan-mobile-decision-card .scan-ai-market-mobile-line) { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 0; +} + +.root :global(.scan-mobile-decision-card .scan-ai-market-decision) { + display: none; +} + +.root :global(.scan-mobile-decision-folds) { + display: grid; + gap: 10px; +} + +.root :global(.scan-mobile-fold) { + padding: 13px 14px; +} + +.root :global(.scan-mobile-fold summary) { + cursor: pointer; + list-style: none; + margin-bottom: 0; +} + +.root :global(.scan-mobile-fold summary::-webkit-details-marker) { + display: none; +} + +.root :global(.scan-mobile-fold[open] summary) { + margin-bottom: 10px; +} + +.root :global(.scan-mobile-fold .scan-ai-city-section.models) { + border: 0; + background: transparent; + padding: 0; +} + +.root :global(.scan-ai-city-collapse svg) { + transition: transform 0.18s ease; +} + + +@media (max-width: 1480px) { + .root :global(.scan-terminal) { + grid-template-columns: minmax(0, 1fr); + } + + .root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) { + position: relative; + top: auto; + grid-column: 1 / -1; + height: auto; + max-height: none; + min-height: auto; + } + + .root :global(.scan-detail-panel) { + grid-column: 1 / -1; + min-height: auto; + } +} + +@media (max-width: 1100px) { + .root :global(.scan-terminal) { + grid-template-columns: 1fr; + } + + .root :global(.scan-filter-panel), + .root :global(.scan-data-grid), + .root :global(.scan-detail-panel), + .root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) { + min-height: auto; + } + + .root :global(.scan-kpi-bar) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .root :global(.scan-topbar) { + flex-direction: column; + align-items: flex-start; + } + + .root :global(.scan-upgrade-announcement) { + grid-template-columns: 1fr; + } + + .root :global(.scan-upgrade-announcement ul) { + justify-content: flex-start; + } + + .root :global(.scan-table-header), + .root :global(.scan-table-row) { + grid-template-columns: 1fr; + } + + .root :global(.scan-table-header) { + display: none; + } + + .root :global(.scan-opportunity-group-head), + .root :global(.scan-opportunity-item) { + grid-template-columns: 1fr; + } + + .root :global(.scan-forecast-city-head), + .root :global(.scan-forecast-row-main), + .root :global(.scan-ai-brief-grid), + .root :global(.scan-ai-city-analysis-grid), + .root :global(.scan-ai-decision-band), + .root :global(.scan-ai-evidence-line) { + grid-template-columns: 1fr; + } + + .root :global(.scan-ai-workspace-head), + .root :global(.scan-opportunity-hero), + .root :global(.scan-ai-city-hero), + .root :global(.scan-ai-city-section-head) { + flex-direction: column; + align-items: flex-start; + } + + .root :global(.scan-opportunity-hero) { + display: flex; + } + + .root :global(.scan-ai-workspace-head p), + .root :global(.scan-ai-city-hero-side) { + text-align: left; + justify-items: start; + } + + .root :global(.scan-ai-decision-metrics) { + min-width: 0; + grid-template-columns: 1fr; + } + + .root :global(.scan-ai-market-decision) { + grid-template-columns: 1fr; + } + + .root :global(.scan-opportunity-card-grid), + .root :global(.scan-opportunity-summary) { + grid-template-columns: 1fr; + } + + .root :global(.scan-forecast-city-read) { + justify-items: start; + text-align: left; + } + + .root :global(.scan-forecast-signals), + .root :global(.scan-ai-temperature-line) { + grid-template-columns: 1fr; + } + + .root :global(.scan-opportunity-phase) { + flex-wrap: wrap; + } + + .root :global(.scan-opportunity-branch) { + display: none; + } + + .root :global(.scan-opportunity-stat.edge) { + justify-self: start; + justify-items: start; + } + + .root :global(.scan-opportunity-ai), + .root :global(.scan-v4-analysis) { + grid-column: 1 / -1; + } + + .root :global(.scan-v4-analysis) { + grid-template-columns: 1fr; + } + + .root :global(.scan-trade-cards) { + grid-template-columns: 1fr; + } +} + +@media (max-width: 820px) { + .root :global(.scan-terminal) { + padding: 10px; + } + + .root :global(.scan-kpi-bar) { + grid-template-columns: 1fr; + } + + .root :global(.scan-topbar-tabs) { + gap: 14px; + flex-wrap: wrap; + } + + .root :global(.scan-upgrade-announcement) { + padding: 14px; + } + + .root :global(.scan-upgrade-announcement-copy strong) { + font-size: 16px; + } + + .root :global(.scan-upgrade-announcement ul) { + gap: 6px; + } + + .root :global(.scan-upgrade-announcement li) { + font-size: 11px; + padding: 6px 8px; + } + + .root :global(.scan-ai-city-hero) { + padding: 16px; + } + + .root :global(.scan-ai-city-hero h3) { + margin-bottom: 10px; + font-size: 24px; + } + + .root :global(.scan-ai-city-mobile-priority) { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin: 0 0 12px; + } + + .root :global(.scan-ai-city-mobile-priority span) { + display: grid; + gap: 4px; + border: 1px solid rgba(77, 163, 255, 0.18); + border-radius: 12px; + background: rgba(77, 163, 255, 0.09); + padding: 9px; + } + + .root :global(.scan-ai-city-mobile-priority small) { + color: #9fb2c7; + font-size: 10px; + font-weight: 900; + } + + .root :global(.scan-ai-city-mobile-priority b) { + color: #f3f8ff; + font-size: 13px; + line-height: 1.15; + } + + .root :global(.scan-ai-city-pills) { + display: none; + } + + .root :global(.scan-ai-city-freshness) { + grid-template-columns: 1fr; + } + + .root :global(.scan-ai-decision-band) { + padding: 14px; + } + + .root :global(.scan-ai-decision-band strong) { + font-size: 20px; + } + + .root :global(.scan-ai-decision-long), + .root :global(.scan-ai-decision-reasons), + .root :global(.scan-ai-decision-risk) { + display: none; + } + + .root :global(.scan-ai-market-mobile-line) { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 12px; + border: 1px solid rgba(77, 163, 255, 0.18); + border-radius: 12px; + background: rgba(11, 18, 32, 0.48); + padding: 10px 12px; + } + + .root :global(.scan-ai-market-mobile-line span) { + color: #9fb2c7; + font-size: 12px; + font-weight: 900; + } + + .root :global(.scan-ai-market-mobile-line b) { + color: #e6edf3; + font-size: 13px; + text-align: right; + } + + .root :global(.scan-ai-market-decision) { + margin-top: 10px; + } + + .root :global(.scan-ai-city-ai-read:not([open])) { + padding-bottom: 4px; + } + + .root :global(.scan-list-header) { + flex-direction: column; + align-items: flex-start; + } + + .root :global(.scan-opportunity-groups) { + padding: 12px; + } + + .root :global(.scan-opportunity-models span) { + white-space: normal; + } + + .root :global(.scan-forecast-city-title strong) { + font-size: 21px; + } + + .root :global(.scan-forecast-row-main), + .root :global(.scan-ai-analysis) { + padding: 13px; + } + + .root :global(.scan-forecast-ai-line) { + grid-template-columns: 1fr; + padding: 0 13px 13px; + } +} diff --git a/frontend/components/dashboard/ScanTerminalOpportunity.module.css b/frontend/components/dashboard/ScanTerminalOpportunity.module.css new file mode 100644 index 00000000..54719b90 --- /dev/null +++ b/frontend/components/dashboard/ScanTerminalOpportunity.module.css @@ -0,0 +1,1142 @@ +/* Scan terminal opportunity board, table and overview styles. */ + +/* ── Opportunity Table ── */ +.root :global(.scan-table-container) { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.root :global(.scan-table-header) { + display: grid; + grid-template-columns: 48px 200px 160px 1fr 150px 90px 64px 40px; + align-items: center; + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + border-bottom: 1px solid var(--border-subtle); + position: sticky; + top: 0; + z-index: 1; + background: var(--bg-primary); +} + +.root :global(.scan-table-row) { + display: grid; + grid-template-columns: 48px 200px 160px 1fr 150px 90px 64px 40px; + align-items: center; + background: rgba(13, 17, 23, 0.4); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; + user-select: none; + padding: 12px; +} + +.root :global(.scan-table-row:hover) { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.15); + transform: scale(1.002); +} + +.root :global(.scan-table-row.selected) { + background: rgba(0, 224, 164, 0.08); + border-color: rgba(0, 224, 164, 0.3); + box-shadow: inset 0 0 12px rgba(0, 224, 164, 0.05); +} + +.root :global(.scan-table-row.tradable) { + border-left: 2px solid rgba(0, 224, 164, 0.3); +} + +/* Table cells */ +.root :global(.scan-td) { + display: flex; + align-items: center; +} + +/* Rank */ +.root :global(.scan-rank-circle) { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + background: rgba(0, 224, 164, 0.12); + color: #4DA3FF; +} + +.root :global(.rank-amber .scan-rank-circle) { + background: rgba(255, 176, 32, 0.12); + color: #F59E0B; +} + +.root :global(.rank-purple .scan-rank-circle) { + background: rgba(123, 97, 255, 0.12); + color: #4DA3FF; +} + +.root :global(.rank-neutral .scan-rank-circle) { + background: rgba(255, 255, 255, 0.06); + color: var(--text-muted); +} + +/* City Cell */ +.root :global(.scan-td-city) { + display: flex; + align-items: center; + gap: 10px; +} + +.root :global(.scan-city-thumb) { + width: 48px; + height: 32px; + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; +} + +.root :global(.scan-city-img) { + width: 100%; + height: 100%; + object-fit: cover; +} + +.root :global(.scan-city-img-placeholder) { + width: 100%; + height: 100%; + background: linear-gradient( + 135deg, + rgba(0, 224, 164, 0.1), + rgba(123, 97, 255, 0.1) + ); +} + +.root :global(.scan-city-info) { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.root :global(.scan-city-name) { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.root :global(.scan-city-sub) { + font-size: 11px; + color: var(--text-muted); +} + +/* Time Cell */ +.root :global(.scan-td-time) { + display: flex; + flex-direction: column; + gap: 4px; +} + +.root :global(.scan-time-text) { + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +/* Status Badge */ +.root :global(.scan-status-badge) { + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.04em; + width: fit-content; +} + +.root :global(.scan-status-badge.tone-green) { + background: rgba(0, 224, 164, 0.12); + color: #4DA3FF; +} + +.root :global(.scan-status-badge.tone-amber) { + background: rgba(255, 176, 32, 0.12); + color: #F59E0B; +} + +.root :global(.scan-status-badge.tone-purple) { + background: rgba(123, 97, 255, 0.12); + color: #4DA3FF; +} + +.root :global(.scan-status-badge.tone-neutral) { + background: rgba(255, 255, 255, 0.05); + color: var(--text-muted); +} + +/* Mini Probability Chart */ +.root :global(.scan-mini-chart) { + display: flex; + align-items: flex-end; + gap: 2px; + height: 40px; + padding: 0 4px; +} + +.root :global(.scan-mini-bar-col) { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + flex: 1; + height: 100%; + justify-content: flex-end; +} + +.root :global(.scan-mini-bar) { + width: 100%; + min-width: 12px; + max-width: 20px; + border-radius: 2px 2px 0 0; + background: linear-gradient( + to top, + rgba(0, 224, 164, 0.3), + rgba(0, 224, 164, 0.7) + ); + transition: height 0.3s; +} + +.root :global(.scan-mini-bar-label) { + font-size: 9px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.root :global(.scan-no-data) { + color: var(--text-muted); + font-size: 12px; +} + +/* Action Cell */ +.root :global(.scan-action-text) { + font-size: 12px; + font-weight: 600; + color: #4DA3FF; + white-space: nowrap; +} + +/* Edge */ +.root :global(.scan-edge-value) { + font-size: 14px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.root :global(.scan-edge-value.positive) { + color: #4DA3FF; +} + +.root :global(.scan-edge-value.neutral) { + color: var(--text-muted); +} + +/* Score Ring */ +.root :global(.scan-score-ring) { + position: relative; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; +} + +.root :global(.scan-score-value) { + position: absolute; + font-size: 14px; + font-weight: 800; +} + +/* Favorite */ +.root :global(.scan-fav-icon) { + color: var(--text-muted); + cursor: pointer; + transition: color 0.2s; +} + +.root :global(.scan-fav-icon:hover) { + color: #F59E0B; +} + + +.root :global(.scan-opportunity-groups) { + display: flex; + flex-direction: column; + gap: 14px; + padding: 14px 18px 18px; +} + +.root :global(.scan-opportunity-group) { + border: 1px solid rgba(90, 123, 166, 0.14); + border-radius: 16px; + overflow: hidden; + background: linear-gradient(180deg, rgba(10, 21, 36, 0.94), rgba(8, 17, 29, 0.94)); +} + +.root :global(.scan-opportunity-group.selected), +.root :global(.scan-opportunity-group:has(.scan-opportunity-item.selected)) { + border-color: rgba(23, 217, 139, 0.42); + box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.18); +} + +.root :global(.scan-opportunity-group-head) { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: center; + width: 100%; + padding: 16px 18px 14px; + border: 0; + border-bottom: 1px solid rgba(90, 123, 166, 0.1); + background: rgba(13, 28, 48, 0.62); + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.root :global(.scan-opportunity-group-head:hover) { + background: rgba(18, 38, 64, 0.78); +} + +.root :global(.scan-opportunity-group-head:focus-visible) { + outline: 2px solid rgba(34, 211, 238, 0.72); + outline-offset: -2px; +} + +.root :global(.scan-opportunity-city) { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.root :global(.scan-opportunity-city strong) { + color: #eef7ff; + font-size: 18px; + font-weight: 900; +} + +.root :global(.scan-opportunity-models) { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} + +.root :global(.scan-opportunity-models span) { + display: inline-flex; + gap: 5px; + align-items: baseline; + min-width: 0; + padding: 5px 8px; + border: 1px solid rgba(122, 154, 196, 0.14); + border-radius: 8px; + background: rgba(4, 12, 22, 0.28); + color: #9fb4cf; + font-size: 12px; + font-weight: 800; + line-height: 1.2; + white-space: nowrap; +} + +.root :global(.scan-opportunity-models em) { + color: #6f88aa; + font-style: normal; + font-weight: 800; +} + +.root :global(.scan-opportunity-models b) { + overflow: hidden; + color: #dceaff; + font-size: 13px; + font-weight: 900; + text-overflow: ellipsis; +} + +.root :global(.scan-opportunity-phase) { + display: flex; + gap: 10px; + align-items: center; + color: #9fb4cf; + font-size: 13px; + white-space: nowrap; +} + +.root :global(.scan-opportunity-phase > span) { + color: #eef7ff; + font-weight: 800; +} + +.root :global(.scan-opportunity-phase em) { + color: #9fb4cf; + font-style: normal; +} + +.root :global(.scan-opportunity-items) { + display: grid; + gap: 6px; + padding: 10px 12px 12px; +} + +.root :global(.scan-opportunity-item) { + display: grid; + grid-template-columns: + 16px minmax(96px, 0.7fr) minmax(82px, 0.45fr) + minmax(114px, 0.52fr) minmax(96px, 0.46fr) + minmax(92px, 0.42fr) max-content max-content; + gap: 10px; + align-items: center; + width: 100%; + min-height: 52px; + padding: 8px 14px; + border: 1px solid rgba(90, 123, 166, 0.1); + border-radius: 12px; + background: rgba(4, 12, 22, 0.48); + color: #b8c9e1; + text-align: left; + cursor: pointer; + transition: + background 0.18s ease, + border-color 0.18s ease, + transform 0.18s ease; +} + +.root :global(.scan-opportunity-item:last-child) { + border-bottom: 1px solid rgba(90, 123, 166, 0.1); +} + +.root :global(.scan-opportunity-item:hover) { + background: rgba(22, 48, 78, 0.5); + border-color: rgba(109, 145, 190, 0.2); +} + +.root :global(.scan-opportunity-item.selected) { + background: linear-gradient(180deg, rgba(5, 43, 42, 0.72), rgba(6, 30, 34, 0.72)); + border-color: rgba(23, 217, 139, 0.42); + box-shadow: inset 3px 0 0 rgba(23, 217, 139, 0.82); +} + +.root :global(.scan-opportunity-item.ai-approve) { + border-color: rgba(94, 234, 212, 0.28); +} + +.root :global(.scan-opportunity-item.ai-veto) { + opacity: 0.72; +} + +.root :global(.scan-opportunity-item.ai-veto .scan-opportunity-action) { + color: #9FB2C7; +} + +.root :global(.scan-opportunity-branch) { + position: relative; + align-self: stretch; + width: 16px; +} + +.root :global(.scan-opportunity-branch::before) { + position: absolute; + top: -18px; + bottom: 50%; + left: 7px; + width: 1px; + background: rgba(122, 154, 196, 0.28); + content: ""; +} + +.root :global(.scan-opportunity-branch i) { + position: absolute; + top: 50%; + left: 7px; + width: 8px; + height: 1px; + background: rgba(122, 154, 196, 0.32); +} + +.root :global(.scan-opportunity-trade) { + display: flex; + min-width: 0; + align-items: center; +} + +.root :global(.scan-opportunity-action) { + overflow: hidden; + font-size: 15px; + font-weight: 900; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.root :global(.scan-opportunity-action.buy) { + color: #22e895; +} + +.root :global(.scan-opportunity-action.sell) { + color: #ff7474; +} + +.root :global(.scan-opportunity-stat) { + display: inline-flex; + align-items: baseline; + gap: 6px; + min-width: 76px; + max-width: 132px; + padding: 6px 8px; + border: 1px solid rgba(90, 123, 166, 0.12); + border-radius: 9px; + background: rgba(13, 28, 48, 0.46); + color: #d0ddf0; +} + +.root :global(.scan-opportunity-stat small) { + overflow: hidden; + max-width: 100%; + color: #718bac; + font-size: 9px; + font-weight: 900; + line-height: 1.1; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} + +.root :global(.scan-opportunity-stat b) { + overflow: hidden; + max-width: 100%; + color: #dceaff; + font-size: 13px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.root :global(.scan-opportunity-stat.edge) { + justify-self: end; + min-width: 92px; + justify-content: flex-end; +} + +.root :global(.scan-opportunity-stat.threshold) { + min-width: 82px; +} + +.root :global(.scan-opportunity-stat b.positive) { + color: #1de28f; +} + +.root :global(.scan-opportunity-stat b.negative) { + color: #ff6868; +} + +.root :global(.scan-opportunity-ai) { + grid-column: 2 / -1; + display: grid; + gap: 3px; + min-width: 0; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid rgba(90, 123, 166, 0.12); + background: rgba(5, 14, 25, 0.5); +} + +.root :global(.scan-opportunity-ai b) { + font-size: 11px; + font-weight: 900; +} + +.root :global(.scan-opportunity-ai small) { + overflow: hidden; + color: #8aa1bf; + font-size: 12px; + line-height: 1.45; + text-overflow: ellipsis; + white-space: nowrap; +} + +.root :global(.scan-opportunity-ai.approve b) { + color: #7dfbe7; +} + +.root :global(.scan-opportunity-ai.downgrade b) { + color: #ffc765; +} + +.root :global(.scan-opportunity-ai.veto b) { + color: #ff8585; +} + +.root :global(.scan-opportunity-ai.neutral b) { + color: #9fc5ff; +} + +.root :global(.scan-opportunity-strength) { + display: inline-flex; + justify-self: end; + align-items: center; + justify-content: center; + min-width: 66px; + min-height: 32px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid rgba(90, 123, 166, 0.16); + background: rgba(13, 28, 48, 0.6); + color: #91a7c4; + font-size: 12px; + font-weight: 900; + white-space: nowrap; +} + +.root :global(.scan-opportunity-strength.strong) { + color: #ff8585; + border-color: rgba(248, 113, 113, 0.32); + background: rgba(248, 113, 113, 0.1); +} + +.root :global(.scan-opportunity-strength.medium) { + color: #ffd166; + border-color: rgba(245, 158, 11, 0.3); + background: rgba(245, 158, 11, 0.1); +} + +.root :global(.scan-opportunity-strength.watch) { + color: #7aa8ff; + border-color: rgba(96, 165, 250, 0.28); + background: rgba(59, 130, 246, 0.1); +} + +.root :global(.scan-opportunity-strength.approve) { + color: #71f7c8; + border-color: rgba(45, 212, 191, 0.34); + background: rgba(20, 184, 166, 0.12); +} + +.root :global(.scan-opportunity-strength.downgrade), +.root :global(.scan-opportunity-strength.watchlist) { + color: #ffd166; + border-color: rgba(245, 158, 11, 0.32); + background: rgba(245, 158, 11, 0.12); +} + +.root :global(.scan-opportunity-strength.veto) { + color: #ff8585; + border-color: rgba(248, 113, 113, 0.34); + background: rgba(248, 113, 113, 0.12); +} + +.root :global(.scan-opportunity-expand) { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 34px; + padding: 0 10px; + border: 1px solid rgba(65, 118, 216, 0.34); + border-radius: 9px; + background: rgba(10, 30, 58, 0.8); + color: #7fb4ff; + font-size: 12px; + font-weight: 900; + white-space: nowrap; + cursor: pointer; +} + +.root :global(.scan-opportunity-expand:hover) { + border-color: rgba(96, 165, 250, 0.5); + background: rgba(20, 47, 86, 0.86); +} + +.root :global(.scan-opportunity-expand:focus-visible) { + outline: 2px solid rgba(34, 211, 238, 0.72); + outline-offset: 2px; +} + +.root :global(.scan-v4-analysis) { + grid-column: 2 / -1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding: 12px; + border: 1px solid rgba(69, 116, 178, 0.18); + border-radius: 12px; + background: + linear-gradient(180deg, rgba(9, 23, 38, 0.9), rgba(6, 16, 28, 0.92)), + radial-gradient(circle at 0 0, rgba(34, 211, 238, 0.1), transparent 30%); +} + +.root :global(.scan-v4-analysis section) { + min-width: 0; + padding: 10px; + border: 1px solid rgba(90, 123, 166, 0.12); + border-radius: 10px; + background: rgba(6, 16, 28, 0.52); +} + +.root :global(.scan-v4-analysis section:first-child), +.root :global(.scan-v4-analysis .scan-v4-evidence) { + grid-column: 1 / -1; +} + +.root :global(.scan-v4-analysis strong) { + display: block; + color: #6FB7FF; + font-size: 12px; + font-weight: 900; + letter-spacing: 0.02em; + text-transform: none; +} + +.root :global(.scan-v4-analysis p) { + margin: 7px 0 0; + color: #c2d2e7; + font-size: 13px; + font-weight: 700; + line-height: 1.5; +} + +.root :global(.scan-v4-analysis ul) { + display: grid; + gap: 7px; + margin: 8px 0 0; + padding-left: 17px; + color: #aabdd6; + font-size: 12px; + font-weight: 700; + line-height: 1.45; +} + +.root :global(.scan-v4-evidence > div) { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 8px; +} + +.root :global(.scan-v4-evidence > div > span), +.root :global(.scan-v4-model-sources span) { + display: inline-flex; + align-items: baseline; + gap: 5px; + min-height: 28px; + padding: 5px 8px; + border: 1px solid rgba(122, 154, 196, 0.14); + border-radius: 8px; + background: rgba(13, 28, 48, 0.58); + color: #d4e4f8; + font-size: 12px; + font-weight: 800; +} + +.root :global(.scan-v4-model-sources em) { + color: #839abd; + font-style: normal; + font-weight: 900; +} + +.root :global(.scan-v4-model-sources b) { + color: #eef7ff; + font-size: 12px; + font-weight: 900; +} + +.root :global(.scan-opportunity-group) { + overflow: visible; + border-radius: 14px; +} + +.root :global(.scan-opportunity-items) { + gap: 10px; + padding: 10px 12px 14px; +} + +.root :global(.scan-opportunity-item) { + display: flex; + flex-direction: column; + gap: 10px; + align-items: stretch; + min-height: 0; + padding: 0; + overflow: visible; + border-color: rgba(89, 128, 178, 0.16); + border-radius: 14px; + background: rgba(6, 18, 32, 0.72); +} + +.root :global(.scan-opportunity-item.expanded) { + border-color: rgba(23, 217, 139, 0.42); + background: + linear-gradient(180deg, rgba(8, 29, 43, 0.94), rgba(5, 17, 31, 0.96)), + radial-gradient(circle at 12% 0%, rgba(20, 184, 166, 0.12), transparent 36%); + box-shadow: + inset 0 1px 0 rgba(125, 251, 231, 0.09), + 0 18px 42px rgba(0, 0, 0, 0.2); +} + +.root :global(.scan-opportunity-summary-row) { + display: grid; + grid-template-columns: + 4px minmax(128px, 180px) minmax(360px, 1fr) + max-content max-content; + gap: 10px; + align-items: stretch; + padding: 12px 14px; +} + +.root :global(.scan-opportunity-branch) { + align-self: stretch; + width: 4px; + border-radius: 999px; + background: linear-gradient(180deg, rgba(23, 217, 139, 0.92), rgba(34, 211, 238, 0.28)); +} + +.root :global(.scan-opportunity-branch::before), +.root :global(.scan-opportunity-branch i) { + display: none; +} + +.root :global(.scan-opportunity-metrics) { + display: grid; + grid-template-columns: repeat(4, minmax(96px, 1fr)); + gap: 8px; + min-width: 0; +} + +.root :global(.scan-opportunity-stat) { + display: grid; + align-items: start; + gap: 3px; + min-width: 0; + max-width: none; + width: 100%; + padding: 8px 10px; + border-color: rgba(90, 123, 166, 0.14); + background: rgba(8, 25, 43, 0.62); +} + +.root :global(.scan-opportunity-stat.edge) { + justify-self: stretch; + justify-content: stretch; + justify-items: start; + min-width: 0; +} + +.root :global(.scan-opportunity-trade) { + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 2px; +} + +.root :global(.scan-opportunity-action) { + white-space: normal; +} + +.root :global(.scan-opportunity-ai) { + display: grid; + grid-column: auto; + gap: 4px; + margin: 0 14px; + padding: 11px 13px; + border-color: rgba(64, 104, 168, 0.16); + background: rgba(5, 15, 27, 0.64); +} + +.root :global(.scan-opportunity-ai small) { + overflow: visible; + color: #a9bdd8; + white-space: normal; + text-overflow: clip; +} + + +.root :global(.scan-opportunity-overview) { + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + border: 1px solid rgba(77, 163, 255, 0.16); + border-radius: 18px; + background: + radial-gradient(circle at 12% 0%, rgba(77, 163, 255, 0.16), transparent 32%), + #0b1220; + padding: 18px; +} + +.root :global(.scan-opportunity-overview.loading), +.root :global(.scan-opportunity-overview.empty) { + display: grid; + place-items: center; + align-content: center; + gap: 12px; + text-align: center; + color: #9fb2c7; +} + +.root :global(.scan-opportunity-overview.empty strong) { + color: #e6edf3; + font-size: 22px; +} + +.root :global(.scan-opportunity-overview.empty button) { + border: 1px solid rgba(77, 163, 255, 0.38); + border-radius: 999px; + background: rgba(77, 163, 255, 0.12); + color: #9ecbff; + cursor: pointer; + font-weight: 900; + padding: 10px 16px; +} + +.root :global(.scan-opportunity-hero) { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr); + gap: 18px; + align-items: stretch; + margin-bottom: 18px; +} + +.root :global(.scan-opportunity-hero > div:first-child) { + border: 1px solid rgba(77, 163, 255, 0.2); + border-radius: 18px; + background: rgba(17, 26, 46, 0.78); + padding: 18px; +} + +.root :global(.scan-opportunity-hero span) { + color: #4da3ff; + font-size: 12px; + font-weight: 900; +} + +.root :global(.scan-opportunity-hero strong) { + display: block; + margin-top: 4px; + color: #e6edf3; + font-size: 26px; + line-height: 1.15; +} + +.root :global(.scan-opportunity-hero p) { + max-width: 680px; + margin: 9px 0 0; + color: #9fb2c7; + font-size: 13px; + line-height: 1.6; +} + +.root :global(.scan-opportunity-summary) { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.root :global(.scan-opportunity-summary span) { + display: grid; + gap: 5px; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 14px; + background: rgba(13, 17, 23, 0.52); + color: #9fb2c7; + padding: 12px; +} + +.root :global(.scan-opportunity-summary b) { + color: #e6edf3; + font-size: 18px; +} + +.root :global(.scan-opportunity-lanes) { + display: grid; + gap: 16px; +} + +.root :global(.scan-opportunity-lane) { + border: 1px solid rgba(77, 163, 255, 0.14); + border-radius: 18px; + background: rgba(13, 17, 23, 0.38); + padding: 14px; +} + +.root :global(.scan-opportunity-lane-head) { + display: flex; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.root :global(.scan-opportunity-lane-head strong) { + color: #e6edf3; + font-size: 17px; +} + +.root :global(.scan-opportunity-lane-head p) { + margin: 3px 0 0; + color: #9fb2c7; + font-size: 12px; +} + +.root :global(.scan-opportunity-lane-head > span) { + display: grid; + place-items: center; + min-width: 32px; + height: 32px; + border-radius: 999px; + background: rgba(77, 163, 255, 0.12); + color: #9ecbff; + font-weight: 900; +} + +.root :global(.scan-opportunity-card-grid) { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.root :global(.scan-opportunity-decision-card) { + display: grid; + gap: 12px; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 16px; + background: rgba(17, 26, 46, 0.78); + color: inherit; + cursor: pointer; + padding: 14px; + transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease; +} + +.root :global(.scan-opportunity-decision-card:hover), +.root :global(.scan-opportunity-decision-card.selected) { + border-color: rgba(77, 163, 255, 0.42); + background: rgba(20, 34, 58, 0.9); + transform: translateY(-1px); +} + +.root :global(.scan-opportunity-decision-card.trade) { + border-color: rgba(34, 197, 94, 0.34); +} + +.root :global(.scan-opportunity-decision-card.wait) { + border-color: rgba(245, 158, 11, 0.34); +} + +.root :global(.scan-opportunity-decision-card.risk), +.root :global(.scan-opportunity-decision-card.avoid) { + border-color: rgba(239, 68, 68, 0.32); +} + +.root :global(.scan-opportunity-decision-head), +.root :global(.scan-opportunity-decision-primary), +.root :global(.scan-opportunity-decision-foot) { + display: flex; + gap: 10px; + justify-content: space-between; +} + +.root :global(.scan-opportunity-decision-head span), +.root :global(.scan-opportunity-decision-primary span), +.root :global(.scan-opportunity-decision-foot small) { + color: #9fb2c7; + font-size: 11px; + font-weight: 800; +} + +.root :global(.scan-opportunity-decision-head strong) { + display: block; + margin-top: 3px; + color: #e6edf3; + font-size: 18px; +} + +.root :global(.scan-opportunity-decision-head > b) { + align-self: start; + border-radius: 999px; + background: rgba(77, 163, 255, 0.14); + color: #9ecbff; + font-size: 12px; + padding: 6px 10px; + white-space: nowrap; +} + +.root :global(.scan-opportunity-decision-card.trade .scan-opportunity-decision-head > b) { + background: rgba(34, 197, 94, 0.14); + color: #86efac; +} + +.root :global(.scan-opportunity-decision-card.wait .scan-opportunity-decision-head > b) { + background: rgba(245, 158, 11, 0.14); + color: #facc15; +} + +.root :global(.scan-opportunity-decision-card.risk .scan-opportunity-decision-head > b), +.root :global(.scan-opportunity-decision-card.avoid .scan-opportunity-decision-head > b) { + background: rgba(239, 68, 68, 0.14); + color: #fca5a5; +} + +.root :global(.scan-opportunity-decision-primary span) { + display: grid; + gap: 4px; + flex: 1; + border-radius: 12px; + background: rgba(13, 17, 23, 0.52); + padding: 9px; +} + +.root :global(.scan-opportunity-decision-primary b) { + color: #e6edf3; + font-size: 14px; +} + +.root :global(.scan-opportunity-decision-card p) { + min-height: 38px; + margin: 0; + color: #b6c7db; + font-size: 12px; + line-height: 1.55; +} + +.root :global(.scan-opportunity-decision-foot) { + flex-wrap: wrap; + justify-content: flex-start; +} + +.root :global(.scan-opportunity-decision-foot small) { + border-radius: 999px; + background: rgba(77, 163, 255, 0.08); + padding: 5px 8px; +} + +.root :global(.scan-opportunity-decision-card button) { + justify-self: start; + border: 1px solid rgba(77, 163, 255, 0.34); + border-radius: 10px; + background: rgba(77, 163, 255, 0.12); + color: #9ecbff; + cursor: pointer; + font-size: 12px; + font-weight: 900; + padding: 8px 10px; +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/aiCityForecastStreamState.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/aiCityForecastStreamState.test.ts new file mode 100644 index 00000000..24a0c580 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/aiCityForecastStreamState.test.ts @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import { + buildAiCityErrorForecastState, + buildAiCityForecastCacheKey, + buildAiCityForecastKey, + buildAiCityProgressForecastState, + buildAiCityReadyForecastState, + readReadyCachedAiForecastState, +} from "@/components/dashboard/scan-terminal/ai-city-forecast-stream-state"; +import { readCachedPayload, writeCachedPayload } from "@/components/dashboard/scan-terminal/scan-terminal-cache"; +import type { AiCityForecastPayload, AiCityForecastState } from "@/components/dashboard/scan-terminal/types"; +import type { CityDetail } from "@/lib/dashboard-types"; + +function installLocalStorageMock() { + const store = new Map(); + const localStorage = { + clear: () => store.clear(), + getItem: (key: string) => store.get(key) ?? null, + removeItem: (key: string) => { + store.delete(key); + }, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + }; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { localStorage }, + }); + return localStorage; +} + +function cityDetail(extra: Partial = {}): CityDetail { + return { + airport_current: { + raw_metar: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012", + temp: 21, + }, + current: { + temp: 21, + }, + local_date: "2026-04-28", + metar_status: { + last_observation_time: "2026-04-28T00:00:00Z", + stale_for_today: false, + }, + name: "Test City", + temp_symbol: "°C", + ...extra, + } as CityDetail; +} + +function readyPayload(extra: Partial = {}): AiCityForecastPayload { + return { + city_forecast: { + confidence: "medium", + final_judgment_en: "Centered near 25°C.", + final_judgment_zh: "预计最高温以 25°C 为中枢。", + metar_read_en: "Latest METAR supports the path.", + metar_read_zh: "最新 METAR 支撑当前路径。", + model_cluster_note_en: "Models are clustered.", + model_cluster_note_zh: "模型较集中。", + predicted_max: 25, + range_high: 26, + range_low: 24, + reasoning_en: "Evidence is aligned.", + reasoning_zh: "证据一致。", + risks_en: [], + risks_zh: [], + unit: "°C", + }, + status: "ready", + ...extra, + }; +} + +export function runTests() { + const storage = installLocalStorageMock(); + storage.clear(); + + const forecastKey = buildAiCityForecastKey({ + detail: cityDetail(), + detailCityName: "Test City", + locale: "zh-CN", + report: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012", + }); + const cacheKey = buildAiCityForecastCacheKey(forecastKey); + const payload = readyPayload(); + writeCachedPayload(cacheKey, payload); + + const cachedReady = readReadyCachedAiForecastState(cacheKey, 0); + assert.equal(cachedReady?.status, "ready"); + assert.equal(cachedReady?.payload?.city_forecast?.predicted_max, 25); + + const degradedCacheKey = `${cacheKey}:degraded`; + writeCachedPayload(degradedCacheKey, readyPayload({ degraded: true })); + assert.equal(readReadyCachedAiForecastState(degradedCacheKey, 0), null); + assert.equal(readCachedPayload(degradedCacheKey, 60 * 60 * 1000), null); + + const currentLoading: AiCityForecastState = { + status: "loading", + streamText: "已有快速判断", + }; + const callingAiProgress = buildAiCityProgressForecastState({ + cacheKey: `${cacheKey}:progress`, + current: currentLoading, + isEn: false, + progress: { + message_zh: "DeepSeek 正在补充机场报文细节", + stage: "calling_ai", + }, + }); + assert.equal(callingAiProgress?.streamText, "已有快速判断"); + + const errorState = buildAiCityErrorForecastState({ + cacheKey: `${cacheKey}:error`, + detail: cityDetail(), + error: new Error("timeout"), + isEn: false, + report: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012", + }); + assert.equal(errorState.status, "ready"); + assert.equal(errorState.payload?.status, "timeout_fallback"); + assert.match(errorState.payload?.reason_zh || "", /DeepSeek|DEB|METAR/); + + const hkoState = buildAiCityErrorForecastState({ + cacheKey: `${cacheKey}:hko`, + detail: cityDetail({ + airport_current: null, + current: { + settlement_source: "hko", + temp: 30, + }, + settlement_station: { + settlement_source: "hko", + }, + } as unknown as Partial), + error: new Error("timeout"), + isEn: false, + report: "", + }); + const hkoRead = hkoState.payload?.city_forecast?.metar_read_zh || ""; + assert.doesNotMatch(hkoRead, /METAR|机场报文/); + assert.match(hkoRead, /官方观测|30\.0°C/); + + const degradedReadyState = buildAiCityReadyForecastState({ + cacheKey: `${cacheKey}:ready-degraded`, + detail: cityDetail(), + isEn: false, + payload: readyPayload({ degraded: true }), + report: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012", + }); + assert.equal(degradedReadyState.status, "ready"); + assert.equal(readCachedPayload(`${cacheKey}:ready-degraded`, 60 * 60 * 1000), null); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/marketScanState.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/marketScanState.test.ts new file mode 100644 index 00000000..b10f394f --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/marketScanState.test.ts @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { + buildCityMarketScanCacheKey, + deriveCityMarketScanView, + resolveCityMarketScanSnapshot, + writeCachedCityMarketScan, +} from "@/components/dashboard/scan-terminal/market-scan-state"; +import type { RemoteData } from "@/components/dashboard/scan-terminal/scan-terminal-client"; +import type { CityDetail, MarketScan } from "@/lib/dashboard-types"; + +function installLocalStorageMock() { + const store = new Map(); + const localStorage = { + clear: () => store.clear(), + getItem: (key: string) => store.get(key) ?? null, + removeItem: (key: string) => { + store.delete(key); + }, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + }; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { localStorage }, + }); + return localStorage; +} + +function marketScan(label = "cached"): MarketScan { + return { + generated_at: "2026-04-28T00:00:00Z", + label, + } as unknown as MarketScan; +} + +function cityDetail(extra: Partial = {}): CityDetail { + return { + local_date: "2026-04-28", + name: "Test City", + ...extra, + } as CityDetail; +} + +export function runTests() { + const storage = installLocalStorageMock(); + storage.clear(); + + const embeddedScan = marketScan("embedded"); + const embeddedSnapshot = resolveCityMarketScanSnapshot({ + detail: cityDetail({ market_scan: embeddedScan }), + detailCityName: "Test City", + enabled: true, + }); + assert.equal(embeddedSnapshot.action, "success"); + if (embeddedSnapshot.action === "success") { + assert.equal(embeddedSnapshot.payload, embeddedScan); + assert.equal(embeddedSnapshot.shouldWriteCache, true); + } + + const cacheKey = buildCityMarketScanCacheKey({ + detailCityName: "Test City", + localDate: "2026-04-28", + }); + const cachedScan = marketScan("cached"); + writeCachedCityMarketScan(cacheKey, cachedScan); + const cachedSnapshot = resolveCityMarketScanSnapshot({ + detail: cityDetail(), + detailCityName: "Test City", + enabled: false, + }); + assert.equal(cachedSnapshot.action, "success"); + if (cachedSnapshot.action === "success") { + assert.equal((cachedSnapshot.payload as unknown as { label: string }).label, "cached"); + } + + storage.clear(); + assert.equal( + resolveCityMarketScanSnapshot({ + detail: cityDetail(), + detailCityName: "Test City", + enabled: false, + }).action, + "reset", + ); + assert.equal( + resolveCityMarketScanSnapshot({ + detail: cityDetail(), + detailCityName: "Test City", + enabled: true, + }).action, + "fetch", + ); + + const previous = marketScan("previous"); + const loadingRemote: RemoteData = { + previous, + status: "loading", + }; + const loadingView = deriveCityMarketScanView({ + detailMarketScan: null, + marketRemote: loadingRemote, + }); + assert.equal(loadingView.marketStatus, "loading"); + assert.equal(loadingView.marketScan, previous); + + const errorWithPrevious = deriveCityMarketScanView({ + detailMarketScan: null, + marketRemote: { + error: "network", + previous, + status: "error", + }, + }); + assert.equal(errorWithPrevious.marketStatus, "ready"); + assert.equal(errorWithPrevious.marketScan, previous); + + const errorWithoutPrevious = deriveCityMarketScanView({ + detailMarketScan: null, + marketRemote: { + error: "network", + status: "error", + }, + }); + assert.equal(errorWithoutPrevious.marketStatus, "failed"); + assert.equal(errorWithoutPrevious.marketScan, null); +} diff --git a/frontend/components/dashboard/scan-terminal/ai-city-forecast-stream-state.ts b/frontend/components/dashboard/scan-terminal/ai-city-forecast-stream-state.ts new file mode 100644 index 00000000..203cf06f --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/ai-city-forecast-stream-state.ts @@ -0,0 +1,346 @@ +import type { AiCityStreamProgress } from "@/components/dashboard/scan-terminal/scan-terminal-client"; +import { + buildStorageKey, + readCachedPayload, + removeCachedPayload, + writeCachedPayload, +} from "@/components/dashboard/scan-terminal/scan-terminal-cache"; +import type { + AiCityForecastPayload, + AiCityForecastState, +} from "@/components/dashboard/scan-terminal/types"; +import type { CityDetail } from "@/lib/dashboard-types"; +import { normalizeCityKey } from "./decision-utils"; + +const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v6"; +const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000; + +const aiCityForecastStateCache = new Map< + string, + { state: AiCityForecastState; updatedAt: number } +>(); + +function isHkoObservationCity(detail?: CityDetail | null) { + const source = String( + detail?.current?.settlement_source || + detail?.settlement_station?.settlement_source || + "", + ) + .trim() + .toLowerCase(); + return source === "hko"; +} + +export function buildAiCityForecastKey({ + detail, + detailCityName, + locale, + report, +}: { + detail: CityDetail | null; + detailCityName: string; + locale: string; + report: string; +}) { + if (!detail) return ""; + const isHkoObservation = isHkoObservationCity(detail); + const observationSource = isHkoObservation ? "hko" : "metar"; + const observationCurrent = isHkoObservation + ? detail.current || {} + : detail.airport_current || detail.current || {}; + const observationSignature = + (!isHkoObservation ? String(report || "").trim() : "") || + [ + observationSource, + observationCurrent.report_time, + observationCurrent.obs_time_epoch, + observationCurrent.obs_time, + observationCurrent.receipt_time, + observationCurrent.temp, + observationCurrent.max_so_far, + observationCurrent.station_code, + detail.metar_status?.stale_for_today, + detail.metar_status?.last_observation_time, + ] + .filter((part) => part != null && part !== "") + .join("|"); + return [ + normalizeCityKey(detailCityName), + detail.local_date || "", + locale, + observationSignature, + ].join(":"); +} + +export function buildAiCityForecastCacheKey(aiForecastKey: string) { + return buildStorageKey(AI_CITY_FORECAST_CACHE_PREFIX, [aiForecastKey]); +} + +export function buildAiCityForecastRequestKey(cacheKey: string, refreshToken: number) { + return `${cacheKey}:${refreshToken > 0 ? `refresh:${refreshToken}` : "normal"}`; +} + +export function readCachedAiForecastState(key: string) { + const cached = aiCityForecastStateCache.get(key); + if (!cached) return null; + if (Date.now() - cached.updatedAt > AI_CITY_FORECAST_CACHE_TTL_MS) { + aiCityForecastStateCache.delete(key); + return null; + } + return cached.state; +} + +export function writeCachedAiForecastState( + key: string, + state: AiCityForecastState, +) { + if (!key || state.status === "idle") return; + aiCityForecastStateCache.set(key, { + state, + updatedAt: Date.now(), + }); +} + +export function readReadyCachedAiForecastState(cacheKey: string, refreshToken: number) { + if (refreshToken > 0) return null; + const cachedPayload = readCachedPayload( + cacheKey, + AI_CITY_FORECAST_CACHE_TTL_MS, + ); + if (cachedPayload) { + if ( + cachedPayload.status === "ready" && + !cachedPayload.degraded && + cachedPayload.city_forecast + ) { + const readyState: AiCityForecastState = { + payload: cachedPayload, + status: "ready", + }; + writeCachedAiForecastState(cacheKey, readyState); + return readyState; + } + removeCachedPayload(cacheKey); + } + const cachedState = readCachedAiForecastState(cacheKey); + return cachedState?.status === "ready" ? cachedState : null; +} + +function getAiCityStreamProgressText( + progress: AiCityStreamProgress, + isEn: boolean, +) { + const localizedMessage = String( + (isEn ? progress.message_en : progress.message_zh) || + (isEn ? progress.final_judgment_en : progress.final_judgment_zh) || + (isEn ? progress.metar_read_en : progress.metar_read_zh) || + "", + ).trim(); + if (localizedMessage) return localizedMessage; + const rawLength = Number(progress.raw_length); + if (Number.isFinite(rawLength) && rawLength > 0) { + return isEn + ? `DeepSeek is streaming the observation enhancement... ${Math.round(rawLength)} chars received.` + : `DeepSeek 正在流式增强观测解读... 已收到 ${Math.round(rawLength)} 字符。`; + } + return ""; +} + +export function buildAiCityFallbackPayload({ + detail, + error, + isEn, + report, +}: { + detail: CityDetail | null; + error?: unknown; + isEn: boolean; + report: string; +}): AiCityForecastPayload { + const tempSymbol = detail?.temp_symbol || "°C"; + const isHkoObservation = isHkoObservationCity(detail); + const currentTemp = + (isHkoObservation + ? detail?.current?.temp + : detail?.airport_current?.temp ?? + detail?.airport_primary?.temp ?? + detail?.current?.temp) ?? null; + const currentText = + currentTemp != null && Number.isFinite(Number(currentTemp)) + ? `${Number(currentTemp).toFixed(1)}${tempSymbol}` + : isEn + ? "the latest observed temperature" + : "最新实测温度"; + const timeoutLike = /timeout|timed out|504|aborted|超时/i.test(String(error || "")); + const rawMetar = isHkoObservation + ? "" + : String(report || detail?.airport_current?.raw_metar || detail?.current?.raw_metar || "").trim(); + const sourceZh = isHkoObservation ? "香港天文台观测" : "METAR"; + const sourceEn = isHkoObservation ? "Hong Kong Observatory observation" : "METAR"; + const bulletinZh = isHkoObservation ? "官方观测" : "机场报文"; + const bulletinEn = isHkoObservation ? "official observation" : "airport bulletin"; + + const finalZh = timeoutLike + ? `DeepSeek 增强暂未返回;当前先以多模型集中度和最新${sourceZh}快速判断。` + : `当前先以多模型集中度和最新${sourceZh}快速判断。`; + const finalEn = timeoutLike + ? `DeepSeek enhancement is not back yet; use the model cluster and latest ${sourceEn} as the fast working read.` + : `Use the model cluster and latest ${sourceEn} as the fast working read.`; + const metarZh = rawMetar + ? `最新 METAR 显示 ${currentText};当前先作为实况锚点,并结合后续报文确认温度路径。` + : `当前可先参考 ${currentText} 与多模型路径,等待下一次${bulletinZh}更新。`; + const metarEn = rawMetar + ? `Latest METAR shows ${currentText}; use it as the live anchor while later reports confirm the path.` + : `Use ${currentText} and the model path for now while waiting for the next ${bulletinEn}.`; + const reasonZh = `DEB、多模型集合和最新${sourceZh}已足够给出当前方向判断;页面会在 DeepSeek 返回后合并完整机场报文解读。`; + const reasonEn = `DEB, the model cluster and latest ${sourceEn} are enough for the current directional read; the page will merge the full airport-bulletin read when DeepSeek returns.`; + + return { + city_forecast: { + confidence: "low", + final_judgment_en: finalEn, + final_judgment_zh: finalZh, + metar_read_en: metarEn, + metar_read_zh: metarZh, + model_cluster_note_en: "", + model_cluster_note_zh: "", + predicted_max: null, + range_high: null, + range_low: null, + reasoning_en: reasonEn, + reasoning_zh: reasonZh, + risks_en: [], + risks_zh: [], + unit: tempSymbol, + }, + raw_reason: timeoutLike ? "ai_timeout_fallback" : "ai_unavailable_fallback", + reason: isEn ? reasonEn : reasonZh, + reason_en: reasonEn, + reason_zh: reasonZh, + status: timeoutLike ? "timeout_fallback" : "fallback", + }; +} + +export function buildAiCityLoadingForecastState({ + cacheKey, + detail, + isEn, + report, +}: { + cacheKey: string; + detail: CityDetail | null; + isEn: boolean; + report: string; +}) { + const cachedState = readCachedAiForecastState(cacheKey); + const initialFallback = buildAiCityFallbackPayload({ detail, isEn, report }); + const loadingState: AiCityForecastState = + cachedState?.status === "loading" + ? cachedState + : { + status: "loading", + streamText: + (isEn + ? initialFallback.city_forecast?.metar_read_en + : initialFallback.city_forecast?.metar_read_zh) || + (isEn + ? "Reading the latest observation with model fallback ready..." + : "已先用最新观测给出兜底解读,正在等待 DeepSeek 补充…"), + }; + writeCachedAiForecastState(cacheKey, loadingState); + return loadingState; +} + +export function buildAiCityProgressForecastState({ + cacheKey, + current, + isEn, + progress, +}: { + cacheKey: string; + current: AiCityForecastState; + isEn: boolean; + progress: AiCityStreamProgress; +}) { + const progressText = getAiCityStreamProgressText(progress, isEn); + if (!progressText) return null; + const cachedProgressState = readCachedAiForecastState(cacheKey); + const nextStreamText = + progress.stage === "calling_ai" && cachedProgressState?.streamText + ? cachedProgressState.streamText + : progressText; + const cachedNextState: AiCityForecastState = { + ...cachedProgressState, + status: "loading", + streamText: nextStreamText, + }; + writeCachedAiForecastState(cacheKey, cachedNextState); + return { + ...current, + status: "loading", + streamText: + progress.stage === "calling_ai" && current.streamText + ? current.streamText + : progressText, + } satisfies AiCityForecastState; +} + +export function buildAiCityReadyForecastState({ + cacheKey, + detail, + isEn, + payload, + report, +}: { + cacheKey: string; + detail: CityDetail | null; + isEn: boolean; + payload: AiCityForecastPayload; + report: string; +}) { + const usablePayload = + payload?.city_forecast + ? payload + : buildAiCityFallbackPayload({ + detail, + error: payload?.reason || payload?.raw_reason || payload?.status, + isEn, + report, + }); + if (usablePayload.status === "ready" && !usablePayload.degraded) { + writeCachedPayload(cacheKey, usablePayload); + } + const readyState: AiCityForecastState = { + payload: usablePayload, + status: "ready", + }; + writeCachedAiForecastState(cacheKey, readyState); + return readyState; +} + +export function buildAiCityErrorForecastState({ + cacheKey, + detail, + error, + isEn, + report, +}: { + cacheKey: string; + detail: CityDetail | null; + error: unknown; + isEn: boolean; + report: string; +}) { + const fallbackPayload = buildAiCityFallbackPayload({ + detail, + error, + isEn, + report, + }); + const readyState: AiCityForecastState = { + payload: fallbackPayload, + status: "ready", + }; + writeCachedAiForecastState(cacheKey, readyState); + return readyState; +} diff --git a/frontend/components/dashboard/scan-terminal/market-scan-state.ts b/frontend/components/dashboard/scan-terminal/market-scan-state.ts new file mode 100644 index 00000000..ec6369a6 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/market-scan-state.ts @@ -0,0 +1,102 @@ +import { + buildStorageKey, + readCachedPayload, + writeCachedPayload, +} from "@/components/dashboard/scan-terminal/scan-terminal-cache"; +import type { RemoteData } from "@/components/dashboard/scan-terminal/scan-terminal-client"; +import type { CityDetail, MarketScan } from "@/lib/dashboard-types"; +import { normalizeCityKey } from "./decision-utils"; + +const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v3"; +const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000; + +export type CityMarketScanStatus = "idle" | "loading" | "ready" | "failed"; + +export type CityMarketScanSnapshot = + | { action: "reset"; cacheKey?: string } + | { action: "success"; cacheKey: string; payload: MarketScan; shouldWriteCache?: boolean } + | { action: "fetch"; cacheKey: string }; + +export function buildCityMarketScanCacheKey({ + detailCityName, + localDate, +}: { + detailCityName: string; + localDate?: string | null; +}) { + return buildStorageKey(CITY_MARKET_SCAN_CACHE_PREFIX, [ + normalizeCityKey(detailCityName), + localDate || "", + "full", + ]); +} + +export function readCachedCityMarketScan(cacheKey: string) { + return readCachedPayload(cacheKey, CITY_MARKET_SCAN_CACHE_TTL_MS); +} + +export function writeCachedCityMarketScan(cacheKey: string, payload: MarketScan) { + writeCachedPayload(cacheKey, payload); +} + +export function resolveCityMarketScanSnapshot({ + detail, + detailCityName, + enabled, +}: { + detail: CityDetail | null; + detailCityName: string; + enabled: boolean; +}): CityMarketScanSnapshot { + if (!detail) return { action: "reset" }; + const cacheKey = buildCityMarketScanCacheKey({ + detailCityName, + localDate: detail.local_date || "", + }); + if (detail.market_scan) { + return { + action: "success", + cacheKey, + payload: detail.market_scan, + shouldWriteCache: true, + }; + } + const cached = readCachedCityMarketScan(cacheKey); + if (cached) { + return { + action: "success", + cacheKey, + payload: cached, + }; + } + return enabled ? { action: "fetch", cacheKey } : { action: "reset", cacheKey }; +} + +export function deriveCityMarketScanView({ + detailMarketScan, + marketRemote, +}: { + detailMarketScan?: MarketScan | null; + marketRemote: RemoteData; +}) { + const previousMarketScan = + marketRemote.status === "loading" || marketRemote.status === "error" + ? marketRemote.previous ?? null + : null; + const marketScan = + marketRemote.status === "success" + ? marketRemote.data + : previousMarketScan ?? detailMarketScan ?? null; + const marketStatus: CityMarketScanStatus = + marketRemote.status === "success" + ? "ready" + : marketRemote.status === "loading" + ? "loading" + : marketRemote.status === "error" + ? marketScan + ? "ready" + : "failed" + : "idle"; + + return { marketScan, marketStatus }; +} diff --git a/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts b/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts index 226e6e31..9e74a5da 100644 --- a/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts +++ b/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts @@ -29,6 +29,11 @@ export type AiCityStreamProgress = { raw_length?: number | null; }; +export const scanTerminalQueryPolicy = { + autoRefreshMs: 10 * 60_000, + manualForceRefreshCooldownMs: 2 * 60_000, +} as const; + type AiCityStreamEvent = { data: Record; event: string; @@ -100,6 +105,32 @@ export function toRemoteError( }; } +export function shouldSkipManualTerminalRefresh({ + hasCurrentData, + lastForcedRefreshAt, + now = Date.now(), +}: { + hasCurrentData: boolean; + lastForcedRefreshAt: number; + now?: number; +}) { + return ( + hasCurrentData && + lastForcedRefreshAt > 0 && + now - lastForcedRefreshAt < scanTerminalQueryPolicy.manualForceRefreshCooldownMs + ); +} + +export function shouldRunAutoTerminalRefresh({ + documentHidden, + isLoading, +}: { + documentHidden: boolean; + isLoading: boolean; +}) { + return !documentHidden && !isLoading; +} + async function readJsonOrThrow(path: string, init?: RequestInit): Promise { const response = await fetchBackendApi(path, init); if (response.ok) return response.json() as Promise; diff --git a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts index bef5745c..304cd914 100644 --- a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts +++ b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts @@ -1,457 +1,4 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import type { - AiCityForecastPayload, - AiCityForecastState, -} from "@/components/dashboard/scan-terminal/types"; -import { - scanTerminalClient, - type AiCityStreamProgress, - type RemoteData, - toRemoteError, - toRemoteLoading, - toRemoteSuccess, -} from "@/components/dashboard/scan-terminal/scan-terminal-client"; -import { - buildStorageKey, - readCachedPayload, - removeCachedPayload, - writeCachedPayload, -} from "@/components/dashboard/scan-terminal/scan-terminal-cache"; -import type { CityDetail, MarketScan } from "@/lib/dashboard-types"; -import { normalizeCityKey } from "./decision-utils"; - -const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v6"; -const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000; -const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v3"; -const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000; -type CityMarketScanStatus = "idle" | "loading" | "ready" | "failed"; -const aiCityForecastStateCache = new Map< - string, - { state: AiCityForecastState; updatedAt: number } ->(); - -function isHkoObservationCity(detail?: CityDetail | null) { - const source = String( - detail?.current?.settlement_source || - detail?.settlement_station?.settlement_source || - "", - ) - .trim() - .toLowerCase(); - return source === "hko"; -} - -function readCachedAiForecastState(key: string, ttlMs: number) { - const cached = aiCityForecastStateCache.get(key); - if (!cached) return null; - if (Date.now() - cached.updatedAt > ttlMs) { - aiCityForecastStateCache.delete(key); - return null; - } - return cached.state; -} - -function writeCachedAiForecastState(key: string, state: AiCityForecastState) { - if (!key || state.status === "idle") return; - aiCityForecastStateCache.set(key, { - state, - updatedAt: Date.now(), - }); -} - -function getAiCityStreamProgressText(progress: AiCityStreamProgress, isEn: boolean) { - const localizedMessage = String( - (isEn ? progress.message_en : progress.message_zh) || - (isEn ? progress.final_judgment_en : progress.final_judgment_zh) || - (isEn ? progress.metar_read_en : progress.metar_read_zh) || - "", - ).trim(); - if (localizedMessage) return localizedMessage; - const rawLength = Number(progress.raw_length); - if (Number.isFinite(rawLength) && rawLength > 0) { - return isEn - ? `DeepSeek is streaming the observation enhancement... ${Math.round(rawLength)} chars received.` - : `DeepSeek 正在流式增强观测解读... 已收到 ${Math.round(rawLength)} 字符。`; - } - return ""; -} - -function buildAiCityFallbackPayload({ - detail, - error, - isEn, - report, -}: { - detail: CityDetail | null; - error?: unknown; - isEn: boolean; - report: string; -}): AiCityForecastPayload { - const tempSymbol = detail?.temp_symbol || "°C"; - const isHkoObservation = isHkoObservationCity(detail); - const currentTemp = - (isHkoObservation - ? detail?.current?.temp - : detail?.airport_current?.temp ?? - detail?.airport_primary?.temp ?? - detail?.current?.temp) ?? null; - const currentText = - currentTemp != null && Number.isFinite(Number(currentTemp)) - ? `${Number(currentTemp).toFixed(1)}${tempSymbol}` - : isEn - ? "the latest observed temperature" - : "最新实测温度"; - const timeoutLike = /timeout|timed out|504|aborted|超时/i.test(String(error || "")); - const rawMetar = isHkoObservation - ? "" - : String(report || detail?.airport_current?.raw_metar || detail?.current?.raw_metar || "").trim(); - const sourceZh = isHkoObservation ? "香港天文台观测" : "METAR"; - const sourceEn = isHkoObservation ? "Hong Kong Observatory observation" : "METAR"; - const bulletinZh = isHkoObservation ? "官方观测" : "机场报文"; - const bulletinEn = isHkoObservation ? "official observation" : "airport bulletin"; - - const finalZh = timeoutLike - ? `DeepSeek 增强暂未返回;当前先以多模型集中度和最新${sourceZh}快速判断。` - : `当前先以多模型集中度和最新${sourceZh}快速判断。`; - const finalEn = timeoutLike - ? `DeepSeek enhancement is not back yet; use the model cluster and latest ${sourceEn} as the fast working read.` - : `Use the model cluster and latest ${sourceEn} as the fast working read.`; - const metarZh = rawMetar - ? `最新 METAR 显示 ${currentText};当前先作为实况锚点,并结合后续报文确认温度路径。` - : `当前可先参考 ${currentText} 与多模型路径,等待下一次${bulletinZh}更新。`; - const metarEn = rawMetar - ? `Latest METAR shows ${currentText}; use it as the live anchor while later reports confirm the path.` - : `Use ${currentText} and the model path for now while waiting for the next ${bulletinEn}.`; - const reasonZh = `DEB、多模型集合和最新${sourceZh}已足够给出当前方向判断;页面会在 DeepSeek 返回后合并完整机场报文解读。`; - const reasonEn = `DEB, the model cluster and latest ${sourceEn} are enough for the current directional read; the page will merge the full airport-bulletin read when DeepSeek returns.`; - - return { - city_forecast: { - confidence: "low", - final_judgment_en: finalEn, - final_judgment_zh: finalZh, - metar_read_en: metarEn, - metar_read_zh: metarZh, - model_cluster_note_en: "", - model_cluster_note_zh: "", - predicted_max: null, - range_high: null, - range_low: null, - reasoning_en: reasonEn, - reasoning_zh: reasonZh, - risks_en: [], - risks_zh: [], - unit: tempSymbol, - }, - raw_reason: timeoutLike ? "ai_timeout_fallback" : "ai_unavailable_fallback", - reason: isEn ? reasonEn : reasonZh, - reason_en: reasonEn, - reason_zh: reasonZh, - status: timeoutLike ? "timeout_fallback" : "fallback", - }; -} - -export function useAiCityForecast({ - detail, - detailCityName, - isEn, - locale, - report, - enabled = true, -}: { - detail: CityDetail | null; - detailCityName: string; - enabled?: boolean; - isEn: boolean; - locale: string; - report: string; -}) { - const [aiForecast, setAiForecast] = useState({ - status: "idle", - }); - const [aiRefreshToken, setAiRefreshToken] = useState(0); - const aiForecastKey = useMemo( - () => { - if (!detail) return ""; - const isHkoObservation = isHkoObservationCity(detail); - const observationSource = isHkoObservation ? "hko" : "metar"; - const observationCurrent = isHkoObservation - ? detail.current || {} - : detail.airport_current || detail.current || {}; - const observationSignature = - (!isHkoObservation ? String(report || "").trim() : "") || - [ - observationSource, - observationCurrent.report_time, - observationCurrent.obs_time_epoch, - observationCurrent.obs_time, - observationCurrent.receipt_time, - observationCurrent.temp, - observationCurrent.max_so_far, - observationCurrent.station_code, - detail.metar_status?.stale_for_today, - detail.metar_status?.last_observation_time, - ] - .filter((part) => part != null && part !== "") - .join("|"); - return [ - normalizeCityKey(detailCityName), - detail.local_date || "", - locale, - observationSignature, - ].join(":"); - }, - [detail, detailCityName, locale, report], - ); - useEffect(() => { - if (!enabled || !aiForecastKey) { - setAiForecast({ status: "idle" }); - return; - } - let cancelled = false; - const cacheKey = buildStorageKey(AI_CITY_FORECAST_CACHE_PREFIX, [aiForecastKey]); - const requestKey = `${cacheKey}:${aiRefreshToken > 0 ? `refresh:${aiRefreshToken}` : "normal"}`; - const cachedPayload = - aiRefreshToken <= 0 - ? readCachedPayload( - cacheKey, - AI_CITY_FORECAST_CACHE_TTL_MS, - ) - : null; - if (cachedPayload) { - if ( - cachedPayload.status === "ready" && - !cachedPayload.degraded && - cachedPayload.city_forecast - ) { - const readyState: AiCityForecastState = { - payload: cachedPayload, - status: "ready", - }; - writeCachedAiForecastState(cacheKey, readyState); - setAiForecast(readyState); - return () => { - cancelled = true; - }; - } - removeCachedPayload(cacheKey); - } - const cachedState = - aiRefreshToken <= 0 - ? readCachedAiForecastState(cacheKey, AI_CITY_FORECAST_CACHE_TTL_MS) - : null; - if (cachedState?.status === "ready") { - setAiForecast(cachedState); - return () => { - cancelled = true; - }; - } - const initialFallback = buildAiCityFallbackPayload({ detail, isEn, report }); - const loadingState: AiCityForecastState = - cachedState?.status === "loading" - ? cachedState - : { - status: "loading", - streamText: - (isEn - ? initialFallback.city_forecast?.metar_read_en - : initialFallback.city_forecast?.metar_read_zh) || - (isEn - ? "Reading the latest observation with model fallback ready..." - : "已先用最新观测给出兜底解读,正在等待 DeepSeek 补充…"), - }; - writeCachedAiForecastState(cacheKey, loadingState); - setAiForecast(loadingState); - void scanTerminalClient.streamAiCityRead({ - city: detailCityName, - forceRefresh: aiRefreshToken > 0, - locale, - onProgress: (progress) => { - const progressText = getAiCityStreamProgressText(progress, isEn); - if (!progressText) return; - const cachedProgressState = readCachedAiForecastState( - cacheKey, - AI_CITY_FORECAST_CACHE_TTL_MS, - ); - const nextStreamText = - progress.stage === "calling_ai" && cachedProgressState?.streamText - ? cachedProgressState.streamText - : progressText; - writeCachedAiForecastState(cacheKey, { - ...cachedProgressState, - status: "loading", - streamText: nextStreamText, - }); - if (cancelled) return; - setAiForecast((current) => ({ - ...current, - status: "loading", - streamText: - progress.stage === "calling_ai" && current.streamText - ? current.streamText - : progressText, - })); - }, - requestKey, - }) - .then((payload) => { - if (!payload) return; - const usablePayload = - payload?.city_forecast - ? payload - : buildAiCityFallbackPayload({ - detail, - error: payload?.reason || payload?.raw_reason || payload?.status, - isEn, - report, - }); - if (usablePayload.status === "ready" && !usablePayload.degraded) { - writeCachedPayload(cacheKey, usablePayload); - } - writeCachedAiForecastState(cacheKey, { - payload: usablePayload, - status: "ready", - }); - if (!cancelled) { - setAiForecast({ payload: usablePayload, status: "ready" }); - } - }) - .catch((error) => { - const fallbackPayload = buildAiCityFallbackPayload({ - detail, - error, - isEn, - report, - }); - writeCachedAiForecastState(cacheKey, { - payload: fallbackPayload, - status: "ready", - }); - if (!cancelled) { - setAiForecast({ payload: fallbackPayload, status: "ready" }); - } - }); - return () => { - cancelled = true; - }; - }, [aiForecastKey, aiRefreshToken, detail, detailCityName, enabled, isEn, locale, report]); - - const refreshAiForecast = useCallback(() => { - setAiRefreshToken((current) => current + 1); - }, []); - - return { aiForecast, refreshAiForecast }; -} - -export function useCityMarketScan({ - detail, - detailCityName, - enabled = true, -}: { - detail: CityDetail | null; - detailCityName: string; - enabled?: boolean; -}) { - const [marketRemote, setMarketRemote] = useState>( - detail?.market_scan ? toRemoteSuccess(detail.market_scan) : { status: "idle" }, - ); - - useEffect(() => { - if (!detail) { - setMarketRemote({ status: "idle" }); - return; - } - const cacheKey = buildStorageKey(CITY_MARKET_SCAN_CACHE_PREFIX, [ - normalizeCityKey(detailCityName), - detail.local_date || "", - "full", - ]); - let cancelled = false; - if (detail.market_scan) { - setMarketRemote(toRemoteSuccess(detail.market_scan)); - writeCachedPayload(cacheKey, detail.market_scan); - return () => { - cancelled = true; - }; - } - if (!enabled) { - const cached = readCachedPayload( - cacheKey, - CITY_MARKET_SCAN_CACHE_TTL_MS, - ); - if (cached) { - setMarketRemote(toRemoteSuccess(cached)); - } else { - setMarketRemote({ status: "idle" }); - } - return () => { - cancelled = true; - }; - } - const cached = readCachedPayload( - cacheKey, - CITY_MARKET_SCAN_CACHE_TTL_MS, - ); - if (cached) { - setMarketRemote(toRemoteSuccess(cached)); - return () => { - cancelled = true; - }; - } else { - setMarketRemote((current) => toRemoteLoading(current)); - } - const controller = new AbortController(); - void scanTerminalClient.getMarketScan(detailCityName, { - lite: false, - signal: controller.signal, - targetDate: detail.local_date || null, - }) - .then((payload) => { - if (cancelled) return; - if (payload) { - writeCachedPayload(cacheKey, payload); - } - const nextPayload = payload || detail.market_scan || null; - if (nextPayload) { - setMarketRemote(toRemoteSuccess(nextPayload)); - } else { - setMarketRemote({ status: "idle" }); - } - }) - .catch((error) => { - if (cancelled) return; - if (detail.market_scan) { - setMarketRemote(toRemoteSuccess(detail.market_scan)); - } else { - setMarketRemote((current) => toRemoteError(error, current)); - } - }); - return () => { - cancelled = true; - controller.abort(); - }; - }, [detail, detailCityName, enabled]); - - const previousMarketScan = - marketRemote.status === "loading" || marketRemote.status === "error" - ? marketRemote.previous ?? null - : null; - const marketScan = - marketRemote.status === "success" - ? marketRemote.data - : previousMarketScan ?? detail?.market_scan ?? null; - const marketStatus: CityMarketScanStatus = - marketRemote.status === "success" - ? "ready" - : marketRemote.status === "loading" - ? "loading" - : marketRemote.status === "error" - ? marketScan - ? "ready" - : "failed" - : "idle"; - - return { marketRemote, marketScan, marketStatus }; -} +export { useAiCityForecast } from "./use-ai-city-forecast"; +export { useCityMarketScan } from "./use-city-market-scan"; diff --git a/frontend/components/dashboard/scan-terminal/use-ai-city-forecast.ts b/frontend/components/dashboard/scan-terminal/use-ai-city-forecast.ts new file mode 100644 index 00000000..c1574a11 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/use-ai-city-forecast.ts @@ -0,0 +1,128 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { scanTerminalClient } from "@/components/dashboard/scan-terminal/scan-terminal-client"; +import type { AiCityForecastState } from "@/components/dashboard/scan-terminal/types"; +import type { CityDetail } from "@/lib/dashboard-types"; +import { + buildAiCityErrorForecastState, + buildAiCityForecastCacheKey, + buildAiCityForecastKey, + buildAiCityForecastRequestKey, + buildAiCityLoadingForecastState, + buildAiCityProgressForecastState, + buildAiCityReadyForecastState, + readReadyCachedAiForecastState, +} from "./ai-city-forecast-stream-state"; + +export function useAiCityForecast({ + detail, + detailCityName, + isEn, + locale, + report, + enabled = true, +}: { + detail: CityDetail | null; + detailCityName: string; + enabled?: boolean; + isEn: boolean; + locale: string; + report: string; +}) { + const [aiForecast, setAiForecast] = useState({ + status: "idle", + }); + const [aiRefreshToken, setAiRefreshToken] = useState(0); + const aiForecastKey = useMemo( + () => buildAiCityForecastKey({ detail, detailCityName, locale, report }), + [detail, detailCityName, locale, report], + ); + + useEffect(() => { + if (!enabled || !aiForecastKey) { + setAiForecast({ status: "idle" }); + return; + } + let cancelled = false; + const cacheKey = buildAiCityForecastCacheKey(aiForecastKey); + const requestKey = buildAiCityForecastRequestKey(cacheKey, aiRefreshToken); + const readyCachedState = readReadyCachedAiForecastState( + cacheKey, + aiRefreshToken, + ); + if (readyCachedState) { + setAiForecast(readyCachedState); + return () => { + cancelled = true; + }; + } + const loadingState = buildAiCityLoadingForecastState({ + cacheKey, + detail, + isEn, + report, + }); + setAiForecast(loadingState); + void scanTerminalClient.streamAiCityRead({ + city: detailCityName, + forceRefresh: aiRefreshToken > 0, + locale, + onProgress: (progress) => { + if (cancelled) return; + setAiForecast((current) => + buildAiCityProgressForecastState({ + cacheKey, + current, + isEn, + progress, + }) ?? current, + ); + }, + requestKey, + }) + .then((payload) => { + if (!payload) return; + const readyState = buildAiCityReadyForecastState({ + cacheKey, + detail, + isEn, + payload, + report, + }); + if (!cancelled) { + setAiForecast(readyState); + } + }) + .catch((error) => { + const errorState = buildAiCityErrorForecastState({ + cacheKey, + detail, + error, + isEn, + report, + }); + if (!cancelled) { + setAiForecast(errorState); + } + }); + return () => { + cancelled = true; + }; + }, [ + aiForecastKey, + aiRefreshToken, + detail, + detailCityName, + enabled, + isEn, + locale, + report, + ]); + + const refreshAiForecast = useCallback(() => { + setAiRefreshToken((current) => current + 1); + }, []); + + return { aiForecast, refreshAiForecast }; +} diff --git a/frontend/components/dashboard/scan-terminal/use-city-market-scan.ts b/frontend/components/dashboard/scan-terminal/use-city-market-scan.ts new file mode 100644 index 00000000..19c7083c --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/use-city-market-scan.ts @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect } from "react"; +import { scanTerminalClient } from "@/components/dashboard/scan-terminal/scan-terminal-client"; +import { useRemoteDataQuery } from "@/components/dashboard/scan-terminal/use-remote-data-query"; +import type { CityDetail, MarketScan } from "@/lib/dashboard-types"; +import { + deriveCityMarketScanView, + resolveCityMarketScanSnapshot, + writeCachedCityMarketScan, +} from "./market-scan-state"; + +export function useCityMarketScan({ + detail, + detailCityName, + enabled = true, +}: { + detail: CityDetail | null; + detailCityName: string; + enabled?: boolean; +}) { + const { + remote: marketRemote, + reset: resetMarketRemote, + run: runMarketScanQuery, + setSuccess: setMarketScanSuccess, + } = useRemoteDataQuery(); + + useEffect(() => { + const snapshot = resolveCityMarketScanSnapshot({ + detail, + detailCityName, + enabled, + }); + if (snapshot.action === "reset") { + resetMarketRemote(); + return; + } + if (snapshot.action === "success") { + setMarketScanSuccess(snapshot.payload); + if (snapshot.shouldWriteCache) { + writeCachedCityMarketScan(snapshot.cacheKey, snapshot.payload); + } + return; + } + void runMarketScanQuery({ + request: (signal) => + scanTerminalClient.getMarketScan(detailCityName, { + lite: false, + signal, + targetDate: detail?.local_date || null, + }), + showLoading: true, + onSuccess: (payload) => { + if (payload) { + writeCachedCityMarketScan(snapshot.cacheKey, payload); + } + }, + }); + }, [ + detail, + detailCityName, + enabled, + resetMarketRemote, + runMarketScanQuery, + setMarketScanSuccess, + ]); + + const { marketScan, marketStatus } = deriveCityMarketScanView({ + detailMarketScan: detail?.market_scan, + marketRemote, + }); + + return { marketRemote, marketScan, marketStatus }; +} diff --git a/frontend/components/dashboard/scan-terminal/use-remote-data-query.ts b/frontend/components/dashboard/scan-terminal/use-remote-data-query.ts new file mode 100644 index 00000000..96ca476e --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/use-remote-data-query.ts @@ -0,0 +1,114 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + toRemoteError, + toRemoteLoading, + toRemoteSuccess, + type RemoteData, +} from "@/components/dashboard/scan-terminal/scan-terminal-client"; + +type RunRemoteQueryOptions = { + onSuccess?: (data: T) => void; + request: (signal: AbortSignal) => Promise; + showLoading?: boolean; +}; + +export function useRemoteDataQuery() { + const [data, setData] = useState(null); + const [remote, setRemote] = useState>({ status: "idle" }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + const requestSeqRef = useRef(0); + const loadingRef = useRef(false); + + const abort = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + }, []); + + const reset = useCallback(() => { + requestSeqRef.current += 1; + abort(); + loadingRef.current = false; + setLoading(false); + setError(null); + setData(null); + setRemote({ status: "idle" }); + }, [abort]); + + const setSuccess = useCallback( + (nextData: T) => { + requestSeqRef.current += 1; + abort(); + loadingRef.current = false; + setLoading(false); + setError(null); + setData(nextData); + setRemote(toRemoteSuccess(nextData)); + }, + [abort], + ); + + const run = useCallback( + async ({ + onSuccess, + request, + showLoading = false, + }: RunRemoteQueryOptions) => { + const requestSeq = ++requestSeqRef.current; + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + if (showLoading) { + loadingRef.current = true; + setLoading(true); + setRemote((current) => toRemoteLoading(current)); + } + setError(null); + try { + const payload = await request(controller.signal); + if (requestSeq !== requestSeqRef.current) return null; + setData(payload); + setRemote(toRemoteSuccess(payload)); + setError(null); + onSuccess?.(payload); + return payload; + } catch (caught) { + if (controller.signal.aborted || requestSeq !== requestSeqRef.current) { + return null; + } + const message = caught instanceof Error ? caught.message : String(caught); + setError(message); + setRemote((current) => toRemoteError(caught, current)); + return null; + } finally { + if (abortRef.current === controller) { + abortRef.current = null; + } + if (showLoading) { + loadingRef.current = false; + setLoading(false); + } + } + }, + [], + ); + + const isLoading = useCallback(() => loadingRef.current, []); + + useEffect(() => abort, [abort]); + + return { + abort, + data, + error, + isLoading, + loading, + remote, + reset, + run, + setSuccess, + }; +} diff --git a/frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts b/frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts index b1c1ccc8..fc487afc 100644 --- a/frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts +++ b/frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts @@ -1,18 +1,15 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { + scanTerminalQueryPolicy, scanTerminalClient, - toRemoteError, - toRemoteLoading, - toRemoteSuccess, - type RemoteData, + shouldRunAutoTerminalRefresh, + shouldSkipManualTerminalRefresh, } from "@/components/dashboard/scan-terminal/scan-terminal-client"; +import { useRemoteDataQuery } from "@/components/dashboard/scan-terminal/use-remote-data-query"; import type { ScanTerminalResponse } from "@/lib/dashboard-types"; -const SCAN_TERMINAL_AUTO_REFRESH_MS = 10 * 60_000; -const SCAN_TERMINAL_MANUAL_REFRESH_COOLDOWN_MS = 2 * 60_000; - export function useScanTerminalQuery({ isPro, proAccessLoading, @@ -20,15 +17,15 @@ export function useScanTerminalQuery({ isPro: boolean; proAccessLoading: boolean; }) { - const [terminalData, setTerminalData] = useState(null); - const [scanRemote, setScanRemote] = useState>({ - status: "idle", - }); - const [scanLoading, setScanLoading] = useState(false); - const [scanError, setScanError] = useState(null); - const scanAbortRef = useRef(null); - const scanRequestSeqRef = useRef(0); - const scanLoadingRef = useRef(false); + const { + data: terminalData, + error: scanError, + isLoading, + loading: scanLoading, + remote: scanRemote, + reset, + run, + } = useRemoteDataQuery(); const lastForcedScanRefreshAtRef = useRef(0); const fetchScanTerminal = useCallback( @@ -40,74 +37,37 @@ export function useScanTerminalQuery({ showLoading?: boolean; } = {}) => { if (proAccessLoading || !isPro) return; - const requestSeq = ++scanRequestSeqRef.current; - scanAbortRef.current?.abort(); - const controller = new AbortController(); - scanAbortRef.current = controller; if (forceRefresh) { lastForcedScanRefreshAtRef.current = Date.now(); } - if (showLoading) { - scanLoadingRef.current = true; - setScanLoading(true); - setScanRemote((current) => toRemoteLoading(current)); - } - setScanError(null); - try { - const payload = await scanTerminalClient.getTerminal({ - forceRefresh, - signal: controller.signal, - }); - if (requestSeq !== scanRequestSeqRef.current) return; - setTerminalData(payload); - setScanRemote(toRemoteSuccess(payload)); - setScanError(null); - } catch (error) { - if (controller.signal.aborted || requestSeq !== scanRequestSeqRef.current) return; - const message = error instanceof Error ? error.message : String(error); - setScanError(message); - setScanRemote((current) => toRemoteError(error, current)); - } finally { - if (scanAbortRef.current === controller) { - scanAbortRef.current = null; - } - if (showLoading) { - scanLoadingRef.current = false; - setScanLoading(false); - } - } + await run({ + request: (signal) => + scanTerminalClient.getTerminal({ + forceRefresh, + signal, + }), + showLoading, + }); }, - [isPro, proAccessLoading], + [isPro, proAccessLoading, run], ); useEffect(() => { if (proAccessLoading) return; if (!isPro) { - scanLoadingRef.current = false; - setScanLoading(false); - setScanError(null); - setTerminalData(null); - setScanRemote({ status: "idle" }); + reset(); return; } void fetchScanTerminal({ forceRefresh: false, showLoading: true }); - }, [fetchScanTerminal, isPro, proAccessLoading]); - - useEffect(() => { - return () => { - scanAbortRef.current?.abort(); - }; - }, []); + }, [fetchScanTerminal, isPro, proAccessLoading, reset]); const refreshScanTerminalManually = useCallback(() => { - const now = Date.now(); - const lastForced = lastForcedScanRefreshAtRef.current; - const withinCooldown = - lastForced > 0 && - now - lastForced < SCAN_TERMINAL_MANUAL_REFRESH_COOLDOWN_MS && - terminalData; - if (withinCooldown) { - setScanError(null); + if ( + shouldSkipManualTerminalRefresh({ + hasCurrentData: Boolean(terminalData), + lastForcedRefreshAt: lastForcedScanRefreshAtRef.current, + }) + ) { return; } void fetchScanTerminal({ forceRefresh: true, showLoading: true }); @@ -116,12 +76,18 @@ export function useScanTerminalQuery({ useEffect(() => { if (proAccessLoading || !isPro) return; const intervalId = window.setInterval(() => { - if (document.hidden) return; - if (scanLoadingRef.current) return; + if ( + !shouldRunAutoTerminalRefresh({ + documentHidden: document.hidden, + isLoading: isLoading(), + }) + ) { + return; + } void fetchScanTerminal({ forceRefresh: true, showLoading: false }); - }, SCAN_TERMINAL_AUTO_REFRESH_MS); + }, scanTerminalQueryPolicy.autoRefreshMs); return () => window.clearInterval(intervalId); - }, [fetchScanTerminal, isPro, proAccessLoading]); + }, [fetchScanTerminal, isLoading, isPro, proAccessLoading]); return { refreshScanTerminalManually, diff --git a/web/scan_city_ai_fallback.py b/web/scan_city_ai_fallback.py new file mode 100644 index 00000000..cb6ac138 --- /dev/null +++ b/web/scan_city_ai_fallback.py @@ -0,0 +1,458 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from web.scan_city_ai_helpers import ( + CITY_AI_REQUIRED_FIELDS, + _CITY_AI_TEXT_FIELDS, + _extract_city_ai_partial_fields, + _provider_response_meta, + _safe_float, + _strip_incomplete_ai_sentence, + _truncate_ai_text, +) + + +def _format_ai_temperature(value: Any, unit: str) -> Optional[str]: + numeric = _safe_float(value) + if numeric is None: + return None + return f"{numeric:.1f}{unit or ''}" + + +def _city_ai_model_cluster_note(ai_input: Dict[str, Any], *, locale: str) -> str: + observation_anchor = ai_input.get("observation_anchor") if isinstance(ai_input.get("observation_anchor"), dict) else {} + is_hko_observation = observation_anchor.get("is_airport_metar") is False + observation_label = ( + "HKO observations" + if locale == "en-US" and is_hko_observation + else "香港天文台观测" + if is_hko_observation + else "METAR" + ) + cluster = ai_input.get("model_cluster") if isinstance(ai_input.get("model_cluster"), dict) else {} + sources = cluster.get("sources") if isinstance(cluster.get("sources"), list) else [] + unit = str(ai_input.get("temp_symbol") or "") + values = [ + _safe_float(item.get("value")) + for item in sources + if isinstance(item, dict) and _safe_float(item.get("value")) is not None + ] + count = len(values) + deb_value = _safe_float((ai_input.get("deb") or {}).get("prediction") if isinstance(ai_input.get("deb"), dict) else None) + if locale == "en-US": + if count <= 0: + return f"No usable model cluster was returned; rely on DEB and {observation_label} only." + if count <= 2: + return f"Only {count} model source(s) are available, so model support is thin and should be treated as context." + range_text = f"{min(values):.1f}{unit} to {max(values):.1f}{unit}" + if deb_value is None: + return f"{count} model sources cluster between {range_text}; DEB support cannot be cross-checked." + supporting = sum(1 for value in values if abs(value - deb_value) <= 2.0) + return f"{supporting}/{count} model sources sit within 2{unit} of DEB; model range is {range_text}." + if count <= 0: + return f"没有可用的多模型集合,只能把 DEB 与{observation_label}作为主要依据。" + if count <= 2: + return f"当前只有 {count} 个模型来源,模型支撑偏薄,只能作为辅助上下文。" + range_text = f"{min(values):.1f}{unit} ~ {max(values):.1f}{unit}" + if deb_value is None: + return f"{count} 个模型集中在 {range_text},但无法与 DEB 做一致性校验。" + supporting = sum(1 for value in values if abs(value - deb_value) <= 2.0) + return f"{supporting}/{count} 个模型落在 DEB ±2{unit} 内;模型区间为 {range_text}。" + + +def _build_city_ai_fallback( + ai_input: Dict[str, Any], + *, + locale: str, + reason: str, + raw_content: str = "", + provider_data: Any = None, +) -> Dict[str, Any]: + unit = str(ai_input.get("temp_symbol") or "") + cluster = ai_input.get("model_cluster") if isinstance(ai_input.get("model_cluster"), dict) else {} + values = [ + _safe_float(item.get("value")) + for item in (cluster.get("sources") if isinstance(cluster.get("sources"), list) else []) + if isinstance(item, dict) and _safe_float(item.get("value")) is not None + ] + deb_value = _safe_float((ai_input.get("deb") or {}).get("prediction") if isinstance(ai_input.get("deb"), dict) else None) + observation_anchor = ai_input.get("observation_anchor") if isinstance(ai_input.get("observation_anchor"), dict) else {} + is_airport_metar = observation_anchor.get("is_airport_metar") is not False + airport_current = ai_input.get("airport_current") if isinstance(ai_input.get("airport_current"), dict) else {} + current_obs = ai_input.get("current") if isinstance(ai_input.get("current"), dict) else {} + metar_context = ai_input.get("metar_context") if isinstance(ai_input.get("metar_context"), dict) else {} + observation_stale = bool( + metar_context.get("stale_for_today") + or airport_current.get("stale_for_today") + or current_obs.get("stale_for_today") + ) + current_temp = _safe_float( + airport_current.get("temp") if is_airport_metar else current_obs.get("temp") + ) + current_max_so_far = _safe_float( + ai_input.get("current_max_so_far") + or current_obs.get("max_so_far") + or airport_current.get("max_so_far") + ) + observed_high_so_far = max( + [value for value in (current_temp, current_max_so_far) if value is not None], + default=None, + ) + observed_high_for_revision = None if observation_stale else observed_high_so_far + predicted = deb_value + if predicted is None and values: + predicted = sum(values) / len(values) + if predicted is None: + predicted = observed_high_so_far + range_low = min(values) if values else predicted + range_high = max(values) if values else predicted + peak = ai_input.get("peak") if isinstance(ai_input.get("peak"), dict) else {} + window_phase = str( + ai_input.get("window_phase") + or peak.get("window_phase") + or peak.get("phase") + or "" + ).strip().lower() + remaining_window_minutes = _safe_float( + ai_input.get("remaining_window_minutes") + if ai_input.get("remaining_window_minutes") is not None + else peak.get("remaining_window_minutes") + if peak.get("remaining_window_minutes") is not None + else peak.get("remaining_minutes") + ) + minutes_until_peak_start = _safe_float( + ai_input.get("minutes_until_peak_start") + if ai_input.get("minutes_until_peak_start") is not None + else peak.get("minutes_until_peak_start") + ) + minutes_until_peak_end = _safe_float( + ai_input.get("minutes_until_peak_end") + if ai_input.get("minutes_until_peak_end") is not None + else peak.get("minutes_until_peak_end") + ) + peak_window_label = str( + ai_input.get("peak_window_label") + or peak.get("peak_window_label") + or peak.get("label") + or "" + ).strip() + peak_has_passed = ( + window_phase in {"post_peak", "past"} + or (minutes_until_peak_end is not None and minutes_until_peak_end < 0) + ) + peak_is_closing = ( + window_phase == "active_peak" + and remaining_window_minutes is not None + and remaining_window_minutes <= 90 + ) + peak_not_started = ( + window_phase in {"early_today", "setup_today", "tomorrow", "week_ahead"} + or (minutes_until_peak_start is not None and minutes_until_peak_start > 0) + ) + model_range_high = range_high + model_range_low = range_low + current_above_predicted = ( + observed_high_for_revision is not None + and predicted is not None + and observed_high_for_revision > predicted + 0.2 + ) + current_above_model_range = ( + observed_high_for_revision is not None + and model_range_high is not None + and observed_high_for_revision > model_range_high + 0.2 + ) + observed_high_break = bool(current_above_predicted or current_above_model_range) + current_below_predicted = ( + observed_high_for_revision is not None + and predicted is not None + and observed_high_for_revision < predicted - 1.5 + ) + current_below_model_range = ( + observed_high_for_revision is not None + and model_range_low is not None + and observed_high_for_revision < model_range_low - 0.2 + ) + observed_low_break = bool(current_below_predicted and (peak_has_passed or peak_is_closing)) + observed_low_lag = bool(current_below_predicted and not observed_low_break) + original_predicted = predicted + if observed_high_break: + predicted = max( + value + for value in (predicted, observed_high_for_revision) + if value is not None + ) + if range_high is not None and observed_high_for_revision is not None: + range_high = max(range_high, observed_high_for_revision) + elif observed_low_break: + predicted = min( + value + for value in (predicted, observed_high_for_revision) + if value is not None + ) + if range_low is not None and observed_high_for_revision is not None: + range_low = min(range_low, observed_high_for_revision) + city = str(ai_input.get("city_display_name") or ai_input.get("city") or "this city") + station = str((airport_current.get("station_code") if is_airport_metar else None) or observation_anchor.get("station_code") or current_obs.get("station_code") or "") + raw_metar = str(airport_current.get("raw_metar") or "").strip() if is_airport_metar else "" + metar_temp = _format_ai_temperature((airport_current.get("temp") if is_airport_metar else current_obs.get("temp")), unit) + obs_time = str((airport_current.get("report_time") or airport_current.get("obs_time")) if is_airport_metar else (current_obs.get("obs_time") or current_obs.get("report_time") or "")).strip() + source_name_zh = "METAR" if is_airport_metar else "香港天文台观测" + source_name_en = "METAR" if is_airport_metar else "Hong Kong Observatory observation" + bulletin_zh = "机场报文" if is_airport_metar else "官方观测" + bulletin_en = "airport-bulletin" if is_airport_metar else "official-station observation" + model_note_zh = _city_ai_model_cluster_note(ai_input, locale="zh-CN") + model_note_en = _city_ai_model_cluster_note(ai_input, locale="en-US") + content_preview = _truncate_ai_text(raw_content, 1000) + partial_ai = _extract_city_ai_partial_fields(raw_content) + looks_like_truncated_json = bool(content_preview.startswith("{") and not content_preview.rstrip().endswith("}")) + reason_preview = _truncate_ai_text(reason, 260) + reason_lower = str(reason or "").lower() + timed_out = "timeout" in reason_lower or "timed out" in reason_lower or "超时" in str(reason or "") + if partial_ai.get("metar_read_zh") or partial_ai.get("metar_read_en"): + metar_zh = str(partial_ai.get("metar_read_zh") or partial_ai.get("metar_read_en") or "").strip() + metar_en = str(partial_ai.get("metar_read_en") or partial_ai.get("metar_read_zh") or "").strip() + elif content_preview and not looks_like_truncated_json: + metar_zh = f"{bulletin_zh}快速解读已先完成;AI 补充摘要:{content_preview}" + metar_en = f"The fast {bulletin_en} read is available; AI supplemental summary: {content_preview}" + elif content_preview: + metar_zh = f"{bulletin_zh}快速解读已先完成;本轮 AI 增强未完整返回,当前以 DEB、多模型与{source_name_zh}为准。" + metar_en = f"The fast {bulletin_en} read is available; this AI enhancement was incomplete, so DEB, model cluster and {source_name_en} carry the read." + elif raw_metar and observation_stale: + metar_zh = f"{station} 可用 METAR 显示 {metar_temp or '温度未知'},报文时间 {obs_time or '未知'};但该观测已标记为过旧,当前只能作为背景参考,不能作为强实况锚点。" + metar_en = f"{station} available METAR shows {metar_temp or 'unknown temperature'} at {obs_time or 'unknown time'}, but the observation is flagged as stale, so treat it as background context rather than a strong live anchor." + elif raw_metar: + metar_zh = f"{station} 最新 METAR 显示 {metar_temp or '温度未知'},报文时间 {obs_time or '未知'};当前先把它作为实况锚点,并结合后续报文确认温度路径。" + metar_en = f"{station} latest METAR shows {metar_temp or 'unknown temperature'} at {obs_time or 'unknown time'}; use it as the live anchor while later reports confirm the path." + else: + metar_zh = f"当前没有可用的{source_name_zh}正文,暂以 DEB、多模型路径与最新实测为主。" + metar_en = f"No raw {source_name_en} text is available, so DEB, latest observations and the model cluster carry the read." + predicted_text = _format_ai_temperature(predicted, unit) or "--" + current_text = _format_ai_temperature(observed_high_so_far, unit) or "--" + original_predicted_text = _format_ai_temperature(original_predicted, unit) or "--" + model_range_high_text = _format_ai_temperature(model_range_high, unit) or "--" + model_range_low_text = _format_ai_temperature(model_range_low, unit) or "--" + peak_label_text_zh = f"({peak_window_label})" if peak_window_label else "" + peak_label_text_en = f" ({peak_window_label})" if peak_window_label else "" + if partial_ai.get("final_judgment_zh") or partial_ai.get("final_judgment_en"): + final_zh = str(partial_ai.get("final_judgment_zh") or partial_ai.get("final_judgment_en") or "").strip() + final_en = str(partial_ai.get("final_judgment_en") or partial_ai.get("final_judgment_zh") or "").strip() + elif partial_ai: + final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;AI 已先完成{bulletin_zh}解读,最高温结论结合 DEB、多模型与最新实测校准。" + final_en = f"{city} daily high is centered near {predicted_text}; AI has already read the {bulletin_en}, with the high calibrated against DEB, the model cluster and latest observations." + elif observed_high_break: + final_zh = f"{city} 最新实测已达 {current_text},高于原先 {original_predicted_text} 中枢;最高温中枢需先上修到至少 {predicted_text} 附近。" + final_en = f"{city} latest observation has reached {current_text}, above the prior {original_predicted_text} center; the daily-high center should be revised up to at least near {predicted_text}." + elif observed_low_break: + final_zh = f"{city} 峰值窗口{peak_label_text_zh}已过或接近结束,实测最高仍约 {current_text},低于原先 {original_predicted_text} 中枢;最高温中枢需先下修到 {predicted_text} 附近。" + final_en = f"{city} peak window{peak_label_text_en} has passed or is nearly over, and observed high is still near {current_text}, below the prior {original_predicted_text} center; revise the daily-high center down toward {predicted_text}." + elif peak_has_passed: + final_zh = f"{city} 峰值窗口{peak_label_text_zh}已过;最高温暂以 {predicted_text} 附近为中枢,并以已观测到的高点为主要校准。" + final_en = f"{city} peak window{peak_label_text_en} has passed; the daily high stays centered near {predicted_text}, calibrated mainly against the observed high so far." + elif observation_stale: + final_zh = f"{city} 最高温暂以 {predicted_text} 附近为中枢;当前可用{source_name_zh}已过旧,先以 DEB 和多模型路径为主。" + final_en = f"{city} daily high is centered near {predicted_text}; the available {source_name_en} is stale, so DEB and the model path carry the read for now." + elif timed_out: + final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;当前已先用 DEB、多模型和{source_name_zh}快速证据模式判断。" + final_en = f"{city} daily high is centered near {predicted_text}; the current read uses the fast DEB/model/{source_name_en} evidence mode." + else: + final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;当前已先用 DEB、多模型和{source_name_zh}快速证据模式判断。" + final_en = f"{city} daily high is centered near {predicted_text}; the current read uses the fast DEB/model/{source_name_en} evidence mode." + if partial_ai: + fallback_reasoning_zh = f"AI {bulletin_zh}解读已用于校准日内节奏;DEB 与多模型集合继续约束最高温中枢,后续{source_name_zh}用于确认是否需要上调或下修。" + fallback_reasoning_en = f"The AI {bulletin_en} read is already used to calibrate the intraday pace; DEB and the model cluster still constrain the high-temperature center, while later {source_name_en} updates confirm whether to revise it." + elif observed_high_break: + fallback_reasoning_zh = f"当前为快速证据模式;最新{source_name_zh}已高于原先 {original_predicted_text} 中枢{(',并超过模型上沿 ' + model_range_high_text) if current_above_model_range else ''},本轮最高温判断应优先承认实测突破并等待完整 AI {bulletin_zh}解读合并。" + fallback_reasoning_en = f"This is the fast evidence mode; latest {source_name_en} is above the prior {original_predicted_text} center{(' and above the model upper edge ' + model_range_high_text) if current_above_model_range else ''}, so the high-temperature read should first acknowledge the observed break and merge the full AI {bulletin_en} read when available." + elif observed_low_break: + fallback_reasoning_zh = f"当前为快速证据模式;峰值窗口已过或接近结束,最新实测高点仍低于原先 {original_predicted_text} 中枢{(',并低于模型下沿 ' + model_range_low_text) if current_below_model_range else ''},本轮最高温判断应优先承认下修压力并等待完整 AI {bulletin_zh}解读合并。" + fallback_reasoning_en = f"This is the fast evidence mode; the peak window has passed or is nearly over, and the observed high remains below the prior {original_predicted_text} center{(' and below the model lower edge ' + model_range_low_text) if current_below_model_range else ''}, so the high-temperature read should first acknowledge downward revision pressure and merge the full AI {bulletin_en} read when available." + elif observed_low_lag and peak_not_started: + fallback_reasoning_zh = f"当前为快速证据模式;最新{source_name_zh}仍低于原先 {original_predicted_text} 中枢,但峰值窗口尚未到来,暂不直接下修,只把后续升温是否追上模型路径作为关键确认。" + fallback_reasoning_en = f"This is the fast evidence mode; latest {source_name_en} remains below the prior {original_predicted_text} center, but the peak window has not arrived, so do not revise down yet and use later warming as the key confirmation." + elif peak_has_passed: + fallback_reasoning_zh = f"当前为快速证据模式;峰值窗口已过,后续{source_name_zh}主要用于确认是否已形成日内高点,而不是继续按待升温路径解读。" + fallback_reasoning_en = f"This is the fast evidence mode; the peak window has passed, so later {source_name_en} updates mainly confirm whether the daily high is already set rather than assuming further warming." + elif observation_stale: + fallback_reasoning_zh = f"当前为快速证据模式;可用{source_name_zh}已过旧,不能作为强实况锚点,暂由 DEB 和多模型集合支撑本轮最高温中枢,等待新的{source_name_zh}确认。" + fallback_reasoning_en = f"This is the fast evidence mode; the available {source_name_en} is stale and should not be used as a strong live anchor, so DEB and the model cluster carry the current daily-high center until a newer {source_name_en} confirms it." + else: + fallback_reasoning_zh = f"当前为快速证据模式;DEB、多模型集合和最新{source_name_zh}共同支撑本轮最高温中枢,完整 AI {bulletin_zh}解读返回后再合并。" + fallback_reasoning_en = f"This is the fast evidence mode; DEB, the model cluster and latest {source_name_en} jointly support the current daily-high center, and the full AI {bulletin_en} read will be merged when available." + reasoning_zh = str(partial_ai.get("reasoning_zh") or "").strip() or fallback_reasoning_zh + reasoning_en = str(partial_ai.get("reasoning_en") or "").strip() or fallback_reasoning_en + if observed_high_break: + risks_zh = [f"最新{source_name_zh}已突破原模型路径,若后续报文继续持平或升温,需要继续上修最高温中枢。"] + risks_en = [f"Latest {source_name_en} has already broken above the prior model path; if later reports hold steady or warm further, keep revising the daily-high center upward."] + elif observed_low_break: + risks_zh = [f"峰值窗口已过或接近结束且实测仍偏低,若后续{source_name_zh}没有反弹,需要继续下修最高温中枢。"] + risks_en = [f"The peak window has passed or is nearly over while observations remain low; if later {source_name_en} does not rebound, keep revising the daily-high center lower."] + elif observed_low_lag: + risks_zh = [f"最新{source_name_zh}仍未追上原模型路径;若峰值窗口前继续偏低,需要下修最高温中枢。"] + risks_en = [f"Latest {source_name_en} has not caught up with the prior model path; if it stays low before the peak window, revise the daily-high center lower."] + elif peak_has_passed: + risks_zh = [f"峰值窗口已过,后续{source_name_zh}若未再创新高,应避免继续上调最高温中枢。"] + risks_en = [f"The peak window has passed; avoid raising the daily-high center unless later {source_name_en} sets a new high."] + elif observation_stale: + risks_zh = [f"当前{source_name_zh}过旧;新报文若明显偏离 DEB 和模型路径,需要重新校准最高温中枢。"] + risks_en = [f"The current {source_name_en} is stale; if a newer report diverges from DEB and the model path, recalibrate the daily-high center."] + else: + risks_zh = [f"后续{source_name_zh}若明显偏离模型路径,需及时修正最高温中枢。"] + risks_en = [f"If later {source_name_en} updates diverge from the model path, revise the daily-high center promptly."] + evidence_guard = { + "observation_stale": observation_stale, + "observed_high_break": observed_high_break, + "observed_low_break": observed_low_break, + "observed_low_lag": observed_low_lag, + "peak_has_passed": peak_has_passed, + "peak_is_closing": peak_is_closing, + "peak_not_started": peak_not_started, + "observed_high_so_far": observed_high_so_far, + "observed_high_for_revision": observed_high_for_revision, + "original_predicted": original_predicted, + "model_range_low": model_range_low, + "model_range_high": model_range_high, + "predicted_max": predicted, + "range_low": range_low, + "range_high": range_high, + } + return { + "predicted_max": partial_ai.get("predicted_max", predicted), + "range_low": partial_ai.get("range_low", range_low), + "range_high": partial_ai.get("range_high", range_high), + "unit": partial_ai.get("unit") or unit, + "confidence": partial_ai.get("confidence") or ("medium" if partial_ai else "low"), + "final_judgment_zh": final_zh, + "final_judgment_en": final_en, + "metar_read_zh": metar_zh, + "metar_read_en": metar_en, + "reasoning_zh": reasoning_zh, + "reasoning_en": reasoning_en, + "risks_zh": risks_zh, + "risks_en": risks_en, + "model_cluster_note_zh": partial_ai.get("model_cluster_note_zh") or model_note_zh, + "model_cluster_note_en": partial_ai.get("model_cluster_note_en") or model_note_en, + "_polyweather_meta": { + **_provider_response_meta(provider_data), + "fallback": True, + "fallback_kind": "partial_ai_json" if partial_ai else "timeout" if timed_out else "non_json", + "looks_like_truncated_json": looks_like_truncated_json, + "fallback_reason": reason_preview, + "raw_content_preview": content_preview, + "partial_ai_fields": sorted(partial_ai.keys()), + "raw_metar": _truncate_ai_text(raw_metar, 1000), + "evidence_guard": evidence_guard, + }, + } + +def _complete_city_ai_payload( + ai_raw: Dict[str, Any], + ai_input: Dict[str, Any], + *, + locale: str, +) -> Dict[str, Any]: + """Fill missing structured fields without marking a provider success as fallback.""" + if not isinstance(ai_raw, dict): + return _build_city_ai_fallback( + ai_input, + locale=locale, + reason="provider output was not a JSON object", + ) + fallback = _build_city_ai_fallback( + ai_input, + locale=locale, + reason="schema completion", + ) + completed: List[str] = [] + out = dict(ai_raw) + trimmed: List[str] = [] + for field in CITY_AI_REQUIRED_FIELDS: + value = out.get(field) + if isinstance(value, str) and field in _CITY_AI_TEXT_FIELDS: + clean_value = _strip_incomplete_ai_sentence(value) + if clean_value != value: + trimmed.append(field) + value = clean_value + out[field] = clean_value + if value is None or value == "" or value == []: + out[field] = fallback.get(field) + completed.append(field) + for field in ("risks_zh", "risks_en"): + value = out.get(field) + if isinstance(value, list): + cleaned_risks = [] + for item in value: + clean_item = _strip_incomplete_ai_sentence(item) + if clean_item: + cleaned_risks.append(clean_item) + if cleaned_risks != value: + trimmed.append(field) + out[field] = cleaned_risks or fallback.get(field) + meta = out.get("_polyweather_meta") + if not isinstance(meta, dict): + meta = {} + if completed: + meta["schema_completed_fields"] = completed + if trimmed: + meta["trimmed_incomplete_fields"] = sorted(set(trimmed)) + guard_meta = (fallback.get("_polyweather_meta") or {}).get("evidence_guard") + if isinstance(guard_meta, dict): + deterministic_fields: List[str] = [] + numeric_guard_active = bool( + guard_meta.get("observed_high_break") + or guard_meta.get("observed_low_break") + or guard_meta.get("observation_stale") + ) + text_guard_active = bool( + numeric_guard_active + or guard_meta.get("peak_has_passed") + or (guard_meta.get("observed_low_lag") and guard_meta.get("peak_not_started")) + ) + if numeric_guard_active: + for field in ("predicted_max", "range_low", "range_high"): + guarded_value = fallback.get(field) + if guarded_value is not None and out.get(field) != guarded_value: + out[field] = guarded_value + deterministic_fields.append(field) + if guard_meta.get("observation_stale"): + guarded_text_fields = ( + "metar_read_zh", + "metar_read_en", + "final_judgment_zh", + "final_judgment_en", + "reasoning_zh", + "reasoning_en", + "risks_zh", + "risks_en", + ) + elif text_guard_active: + guarded_text_fields = ( + "final_judgment_zh", + "final_judgment_en", + "reasoning_zh", + "reasoning_en", + "risks_zh", + "risks_en", + ) + else: + guarded_text_fields = () + for field in guarded_text_fields: + guarded_value = fallback.get(field) + if guarded_value not in (None, "", []) and out.get(field) != guarded_value: + out[field] = guarded_value + deterministic_fields.append(field) + if deterministic_fields: + meta["deterministic_guard_fields"] = sorted(set(deterministic_fields)) + meta["deterministic_guard_reason"] = { + key: guard_meta.get(key) + for key in ( + "observation_stale", + "observed_high_break", + "observed_low_break", + "observed_low_lag", + "peak_has_passed", + ) + if guard_meta.get(key) + } + out["_polyweather_meta"] = meta + return out diff --git a/web/scan_city_ai_helpers.py b/web/scan_city_ai_helpers.py index 16180970..a9018b77 100644 --- a/web/scan_city_ai_helpers.py +++ b/web/scan_city_ai_helpers.py @@ -257,337 +257,6 @@ def _provider_response_meta(data: Any) -> Dict[str, Any]: } -def _format_ai_temperature(value: Any, unit: str) -> Optional[str]: - numeric = _safe_float(value) - if numeric is None: - return None - return f"{numeric:.1f}{unit or ''}" - - -def _city_ai_model_cluster_note(ai_input: Dict[str, Any], *, locale: str) -> str: - observation_anchor = ai_input.get("observation_anchor") if isinstance(ai_input.get("observation_anchor"), dict) else {} - is_hko_observation = observation_anchor.get("is_airport_metar") is False - observation_label = ( - "HKO observations" - if locale == "en-US" and is_hko_observation - else "香港天文台观测" - if is_hko_observation - else "METAR" - ) - cluster = ai_input.get("model_cluster") if isinstance(ai_input.get("model_cluster"), dict) else {} - sources = cluster.get("sources") if isinstance(cluster.get("sources"), list) else [] - unit = str(ai_input.get("temp_symbol") or "") - values = [ - _safe_float(item.get("value")) - for item in sources - if isinstance(item, dict) and _safe_float(item.get("value")) is not None - ] - count = len(values) - deb_value = _safe_float((ai_input.get("deb") or {}).get("prediction") if isinstance(ai_input.get("deb"), dict) else None) - if locale == "en-US": - if count <= 0: - return f"No usable model cluster was returned; rely on DEB and {observation_label} only." - if count <= 2: - return f"Only {count} model source(s) are available, so model support is thin and should be treated as context." - range_text = f"{min(values):.1f}{unit} to {max(values):.1f}{unit}" - if deb_value is None: - return f"{count} model sources cluster between {range_text}; DEB support cannot be cross-checked." - supporting = sum(1 for value in values if abs(value - deb_value) <= 2.0) - return f"{supporting}/{count} model sources sit within 2{unit} of DEB; model range is {range_text}." - if count <= 0: - return f"没有可用的多模型集合,只能把 DEB 与{observation_label}作为主要依据。" - if count <= 2: - return f"当前只有 {count} 个模型来源,模型支撑偏薄,只能作为辅助上下文。" - range_text = f"{min(values):.1f}{unit} ~ {max(values):.1f}{unit}" - if deb_value is None: - return f"{count} 个模型集中在 {range_text},但无法与 DEB 做一致性校验。" - supporting = sum(1 for value in values if abs(value - deb_value) <= 2.0) - return f"{supporting}/{count} 个模型落在 DEB ±2{unit} 内;模型区间为 {range_text}。" - - -def _build_city_ai_fallback( - ai_input: Dict[str, Any], - *, - locale: str, - reason: str, - raw_content: str = "", - provider_data: Any = None, -) -> Dict[str, Any]: - unit = str(ai_input.get("temp_symbol") or "") - cluster = ai_input.get("model_cluster") if isinstance(ai_input.get("model_cluster"), dict) else {} - values = [ - _safe_float(item.get("value")) - for item in (cluster.get("sources") if isinstance(cluster.get("sources"), list) else []) - if isinstance(item, dict) and _safe_float(item.get("value")) is not None - ] - deb_value = _safe_float((ai_input.get("deb") or {}).get("prediction") if isinstance(ai_input.get("deb"), dict) else None) - observation_anchor = ai_input.get("observation_anchor") if isinstance(ai_input.get("observation_anchor"), dict) else {} - is_airport_metar = observation_anchor.get("is_airport_metar") is not False - airport_current = ai_input.get("airport_current") if isinstance(ai_input.get("airport_current"), dict) else {} - current_obs = ai_input.get("current") if isinstance(ai_input.get("current"), dict) else {} - metar_context = ai_input.get("metar_context") if isinstance(ai_input.get("metar_context"), dict) else {} - observation_stale = bool( - metar_context.get("stale_for_today") - or airport_current.get("stale_for_today") - or current_obs.get("stale_for_today") - ) - current_temp = _safe_float( - airport_current.get("temp") if is_airport_metar else current_obs.get("temp") - ) - current_max_so_far = _safe_float( - ai_input.get("current_max_so_far") - or current_obs.get("max_so_far") - or airport_current.get("max_so_far") - ) - observed_high_so_far = max( - [value for value in (current_temp, current_max_so_far) if value is not None], - default=None, - ) - observed_high_for_revision = None if observation_stale else observed_high_so_far - predicted = deb_value - if predicted is None and values: - predicted = sum(values) / len(values) - if predicted is None: - predicted = observed_high_so_far - range_low = min(values) if values else predicted - range_high = max(values) if values else predicted - peak = ai_input.get("peak") if isinstance(ai_input.get("peak"), dict) else {} - window_phase = str( - ai_input.get("window_phase") - or peak.get("window_phase") - or peak.get("phase") - or "" - ).strip().lower() - remaining_window_minutes = _safe_float( - ai_input.get("remaining_window_minutes") - if ai_input.get("remaining_window_minutes") is not None - else peak.get("remaining_window_minutes") - if peak.get("remaining_window_minutes") is not None - else peak.get("remaining_minutes") - ) - minutes_until_peak_start = _safe_float( - ai_input.get("minutes_until_peak_start") - if ai_input.get("minutes_until_peak_start") is not None - else peak.get("minutes_until_peak_start") - ) - minutes_until_peak_end = _safe_float( - ai_input.get("minutes_until_peak_end") - if ai_input.get("minutes_until_peak_end") is not None - else peak.get("minutes_until_peak_end") - ) - peak_window_label = str( - ai_input.get("peak_window_label") - or peak.get("peak_window_label") - or peak.get("label") - or "" - ).strip() - peak_has_passed = ( - window_phase in {"post_peak", "past"} - or (minutes_until_peak_end is not None and minutes_until_peak_end < 0) - ) - peak_is_closing = ( - window_phase == "active_peak" - and remaining_window_minutes is not None - and remaining_window_minutes <= 90 - ) - peak_not_started = ( - window_phase in {"early_today", "setup_today", "tomorrow", "week_ahead"} - or (minutes_until_peak_start is not None and minutes_until_peak_start > 0) - ) - model_range_high = range_high - model_range_low = range_low - current_above_predicted = ( - observed_high_for_revision is not None - and predicted is not None - and observed_high_for_revision > predicted + 0.2 - ) - current_above_model_range = ( - observed_high_for_revision is not None - and model_range_high is not None - and observed_high_for_revision > model_range_high + 0.2 - ) - observed_high_break = bool(current_above_predicted or current_above_model_range) - current_below_predicted = ( - observed_high_for_revision is not None - and predicted is not None - and observed_high_for_revision < predicted - 1.5 - ) - current_below_model_range = ( - observed_high_for_revision is not None - and model_range_low is not None - and observed_high_for_revision < model_range_low - 0.2 - ) - observed_low_break = bool(current_below_predicted and (peak_has_passed or peak_is_closing)) - observed_low_lag = bool(current_below_predicted and not observed_low_break) - original_predicted = predicted - if observed_high_break: - predicted = max( - value - for value in (predicted, observed_high_for_revision) - if value is not None - ) - if range_high is not None and observed_high_for_revision is not None: - range_high = max(range_high, observed_high_for_revision) - elif observed_low_break: - predicted = min( - value - for value in (predicted, observed_high_for_revision) - if value is not None - ) - if range_low is not None and observed_high_for_revision is not None: - range_low = min(range_low, observed_high_for_revision) - city = str(ai_input.get("city_display_name") or ai_input.get("city") or "this city") - station = str((airport_current.get("station_code") if is_airport_metar else None) or observation_anchor.get("station_code") or current_obs.get("station_code") or "") - raw_metar = str(airport_current.get("raw_metar") or "").strip() if is_airport_metar else "" - metar_temp = _format_ai_temperature((airport_current.get("temp") if is_airport_metar else current_obs.get("temp")), unit) - obs_time = str((airport_current.get("report_time") or airport_current.get("obs_time")) if is_airport_metar else (current_obs.get("obs_time") or current_obs.get("report_time") or "")).strip() - source_name_zh = "METAR" if is_airport_metar else "香港天文台观测" - source_name_en = "METAR" if is_airport_metar else "Hong Kong Observatory observation" - bulletin_zh = "机场报文" if is_airport_metar else "官方观测" - bulletin_en = "airport-bulletin" if is_airport_metar else "official-station observation" - model_note_zh = _city_ai_model_cluster_note(ai_input, locale="zh-CN") - model_note_en = _city_ai_model_cluster_note(ai_input, locale="en-US") - content_preview = _truncate_ai_text(raw_content, 1000) - partial_ai = _extract_city_ai_partial_fields(raw_content) - looks_like_truncated_json = bool(content_preview.startswith("{") and not content_preview.rstrip().endswith("}")) - reason_preview = _truncate_ai_text(reason, 260) - reason_lower = str(reason or "").lower() - timed_out = "timeout" in reason_lower or "timed out" in reason_lower or "超时" in str(reason or "") - if partial_ai.get("metar_read_zh") or partial_ai.get("metar_read_en"): - metar_zh = str(partial_ai.get("metar_read_zh") or partial_ai.get("metar_read_en") or "").strip() - metar_en = str(partial_ai.get("metar_read_en") or partial_ai.get("metar_read_zh") or "").strip() - elif content_preview and not looks_like_truncated_json: - metar_zh = f"{bulletin_zh}快速解读已先完成;AI 补充摘要:{content_preview}" - metar_en = f"The fast {bulletin_en} read is available; AI supplemental summary: {content_preview}" - elif content_preview: - metar_zh = f"{bulletin_zh}快速解读已先完成;本轮 AI 增强未完整返回,当前以 DEB、多模型与{source_name_zh}为准。" - metar_en = f"The fast {bulletin_en} read is available; this AI enhancement was incomplete, so DEB, model cluster and {source_name_en} carry the read." - elif raw_metar and observation_stale: - metar_zh = f"{station} 可用 METAR 显示 {metar_temp or '温度未知'},报文时间 {obs_time or '未知'};但该观测已标记为过旧,当前只能作为背景参考,不能作为强实况锚点。" - metar_en = f"{station} available METAR shows {metar_temp or 'unknown temperature'} at {obs_time or 'unknown time'}, but the observation is flagged as stale, so treat it as background context rather than a strong live anchor." - elif raw_metar: - metar_zh = f"{station} 最新 METAR 显示 {metar_temp or '温度未知'},报文时间 {obs_time or '未知'};当前先把它作为实况锚点,并结合后续报文确认温度路径。" - metar_en = f"{station} latest METAR shows {metar_temp or 'unknown temperature'} at {obs_time or 'unknown time'}; use it as the live anchor while later reports confirm the path." - else: - metar_zh = f"当前没有可用的{source_name_zh}正文,暂以 DEB、多模型路径与最新实测为主。" - metar_en = f"No raw {source_name_en} text is available, so DEB, latest observations and the model cluster carry the read." - predicted_text = _format_ai_temperature(predicted, unit) or "--" - current_text = _format_ai_temperature(observed_high_so_far, unit) or "--" - original_predicted_text = _format_ai_temperature(original_predicted, unit) or "--" - model_range_high_text = _format_ai_temperature(model_range_high, unit) or "--" - model_range_low_text = _format_ai_temperature(model_range_low, unit) or "--" - peak_label_text_zh = f"({peak_window_label})" if peak_window_label else "" - peak_label_text_en = f" ({peak_window_label})" if peak_window_label else "" - if partial_ai.get("final_judgment_zh") or partial_ai.get("final_judgment_en"): - final_zh = str(partial_ai.get("final_judgment_zh") or partial_ai.get("final_judgment_en") or "").strip() - final_en = str(partial_ai.get("final_judgment_en") or partial_ai.get("final_judgment_zh") or "").strip() - elif partial_ai: - final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;AI 已先完成{bulletin_zh}解读,最高温结论结合 DEB、多模型与最新实测校准。" - final_en = f"{city} daily high is centered near {predicted_text}; AI has already read the {bulletin_en}, with the high calibrated against DEB, the model cluster and latest observations." - elif observed_high_break: - final_zh = f"{city} 最新实测已达 {current_text},高于原先 {original_predicted_text} 中枢;最高温中枢需先上修到至少 {predicted_text} 附近。" - final_en = f"{city} latest observation has reached {current_text}, above the prior {original_predicted_text} center; the daily-high center should be revised up to at least near {predicted_text}." - elif observed_low_break: - final_zh = f"{city} 峰值窗口{peak_label_text_zh}已过或接近结束,实测最高仍约 {current_text},低于原先 {original_predicted_text} 中枢;最高温中枢需先下修到 {predicted_text} 附近。" - final_en = f"{city} peak window{peak_label_text_en} has passed or is nearly over, and observed high is still near {current_text}, below the prior {original_predicted_text} center; revise the daily-high center down toward {predicted_text}." - elif peak_has_passed: - final_zh = f"{city} 峰值窗口{peak_label_text_zh}已过;最高温暂以 {predicted_text} 附近为中枢,并以已观测到的高点为主要校准。" - final_en = f"{city} peak window{peak_label_text_en} has passed; the daily high stays centered near {predicted_text}, calibrated mainly against the observed high so far." - elif observation_stale: - final_zh = f"{city} 最高温暂以 {predicted_text} 附近为中枢;当前可用{source_name_zh}已过旧,先以 DEB 和多模型路径为主。" - final_en = f"{city} daily high is centered near {predicted_text}; the available {source_name_en} is stale, so DEB and the model path carry the read for now." - elif timed_out: - final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;当前已先用 DEB、多模型和{source_name_zh}快速证据模式判断。" - final_en = f"{city} daily high is centered near {predicted_text}; the current read uses the fast DEB/model/{source_name_en} evidence mode." - else: - final_zh = f"{city} 预计最高温暂以 {predicted_text} 附近为中枢;当前已先用 DEB、多模型和{source_name_zh}快速证据模式判断。" - final_en = f"{city} daily high is centered near {predicted_text}; the current read uses the fast DEB/model/{source_name_en} evidence mode." - if partial_ai: - fallback_reasoning_zh = f"AI {bulletin_zh}解读已用于校准日内节奏;DEB 与多模型集合继续约束最高温中枢,后续{source_name_zh}用于确认是否需要上调或下修。" - fallback_reasoning_en = f"The AI {bulletin_en} read is already used to calibrate the intraday pace; DEB and the model cluster still constrain the high-temperature center, while later {source_name_en} updates confirm whether to revise it." - elif observed_high_break: - fallback_reasoning_zh = f"当前为快速证据模式;最新{source_name_zh}已高于原先 {original_predicted_text} 中枢{(',并超过模型上沿 ' + model_range_high_text) if current_above_model_range else ''},本轮最高温判断应优先承认实测突破并等待完整 AI {bulletin_zh}解读合并。" - fallback_reasoning_en = f"This is the fast evidence mode; latest {source_name_en} is above the prior {original_predicted_text} center{(' and above the model upper edge ' + model_range_high_text) if current_above_model_range else ''}, so the high-temperature read should first acknowledge the observed break and merge the full AI {bulletin_en} read when available." - elif observed_low_break: - fallback_reasoning_zh = f"当前为快速证据模式;峰值窗口已过或接近结束,最新实测高点仍低于原先 {original_predicted_text} 中枢{(',并低于模型下沿 ' + model_range_low_text) if current_below_model_range else ''},本轮最高温判断应优先承认下修压力并等待完整 AI {bulletin_zh}解读合并。" - fallback_reasoning_en = f"This is the fast evidence mode; the peak window has passed or is nearly over, and the observed high remains below the prior {original_predicted_text} center{(' and below the model lower edge ' + model_range_low_text) if current_below_model_range else ''}, so the high-temperature read should first acknowledge downward revision pressure and merge the full AI {bulletin_en} read when available." - elif observed_low_lag and peak_not_started: - fallback_reasoning_zh = f"当前为快速证据模式;最新{source_name_zh}仍低于原先 {original_predicted_text} 中枢,但峰值窗口尚未到来,暂不直接下修,只把后续升温是否追上模型路径作为关键确认。" - fallback_reasoning_en = f"This is the fast evidence mode; latest {source_name_en} remains below the prior {original_predicted_text} center, but the peak window has not arrived, so do not revise down yet and use later warming as the key confirmation." - elif peak_has_passed: - fallback_reasoning_zh = f"当前为快速证据模式;峰值窗口已过,后续{source_name_zh}主要用于确认是否已形成日内高点,而不是继续按待升温路径解读。" - fallback_reasoning_en = f"This is the fast evidence mode; the peak window has passed, so later {source_name_en} updates mainly confirm whether the daily high is already set rather than assuming further warming." - elif observation_stale: - fallback_reasoning_zh = f"当前为快速证据模式;可用{source_name_zh}已过旧,不能作为强实况锚点,暂由 DEB 和多模型集合支撑本轮最高温中枢,等待新的{source_name_zh}确认。" - fallback_reasoning_en = f"This is the fast evidence mode; the available {source_name_en} is stale and should not be used as a strong live anchor, so DEB and the model cluster carry the current daily-high center until a newer {source_name_en} confirms it." - else: - fallback_reasoning_zh = f"当前为快速证据模式;DEB、多模型集合和最新{source_name_zh}共同支撑本轮最高温中枢,完整 AI {bulletin_zh}解读返回后再合并。" - fallback_reasoning_en = f"This is the fast evidence mode; DEB, the model cluster and latest {source_name_en} jointly support the current daily-high center, and the full AI {bulletin_en} read will be merged when available." - reasoning_zh = str(partial_ai.get("reasoning_zh") or "").strip() or fallback_reasoning_zh - reasoning_en = str(partial_ai.get("reasoning_en") or "").strip() or fallback_reasoning_en - if observed_high_break: - risks_zh = [f"最新{source_name_zh}已突破原模型路径,若后续报文继续持平或升温,需要继续上修最高温中枢。"] - risks_en = [f"Latest {source_name_en} has already broken above the prior model path; if later reports hold steady or warm further, keep revising the daily-high center upward."] - elif observed_low_break: - risks_zh = [f"峰值窗口已过或接近结束且实测仍偏低,若后续{source_name_zh}没有反弹,需要继续下修最高温中枢。"] - risks_en = [f"The peak window has passed or is nearly over while observations remain low; if later {source_name_en} does not rebound, keep revising the daily-high center lower."] - elif observed_low_lag: - risks_zh = [f"最新{source_name_zh}仍未追上原模型路径;若峰值窗口前继续偏低,需要下修最高温中枢。"] - risks_en = [f"Latest {source_name_en} has not caught up with the prior model path; if it stays low before the peak window, revise the daily-high center lower."] - elif peak_has_passed: - risks_zh = [f"峰值窗口已过,后续{source_name_zh}若未再创新高,应避免继续上调最高温中枢。"] - risks_en = [f"The peak window has passed; avoid raising the daily-high center unless later {source_name_en} sets a new high."] - elif observation_stale: - risks_zh = [f"当前{source_name_zh}过旧;新报文若明显偏离 DEB 和模型路径,需要重新校准最高温中枢。"] - risks_en = [f"The current {source_name_en} is stale; if a newer report diverges from DEB and the model path, recalibrate the daily-high center."] - else: - risks_zh = [f"后续{source_name_zh}若明显偏离模型路径,需及时修正最高温中枢。"] - risks_en = [f"If later {source_name_en} updates diverge from the model path, revise the daily-high center promptly."] - evidence_guard = { - "observation_stale": observation_stale, - "observed_high_break": observed_high_break, - "observed_low_break": observed_low_break, - "observed_low_lag": observed_low_lag, - "peak_has_passed": peak_has_passed, - "peak_is_closing": peak_is_closing, - "peak_not_started": peak_not_started, - "observed_high_so_far": observed_high_so_far, - "observed_high_for_revision": observed_high_for_revision, - "original_predicted": original_predicted, - "model_range_low": model_range_low, - "model_range_high": model_range_high, - "predicted_max": predicted, - "range_low": range_low, - "range_high": range_high, - } - return { - "predicted_max": partial_ai.get("predicted_max", predicted), - "range_low": partial_ai.get("range_low", range_low), - "range_high": partial_ai.get("range_high", range_high), - "unit": partial_ai.get("unit") or unit, - "confidence": partial_ai.get("confidence") or ("medium" if partial_ai else "low"), - "final_judgment_zh": final_zh, - "final_judgment_en": final_en, - "metar_read_zh": metar_zh, - "metar_read_en": metar_en, - "reasoning_zh": reasoning_zh, - "reasoning_en": reasoning_en, - "risks_zh": risks_zh, - "risks_en": risks_en, - "model_cluster_note_zh": partial_ai.get("model_cluster_note_zh") or model_note_zh, - "model_cluster_note_en": partial_ai.get("model_cluster_note_en") or model_note_en, - "_polyweather_meta": { - **_provider_response_meta(provider_data), - "fallback": True, - "fallback_kind": "partial_ai_json" if partial_ai else "timeout" if timed_out else "non_json", - "looks_like_truncated_json": looks_like_truncated_json, - "fallback_reason": reason_preview, - "raw_content_preview": content_preview, - "partial_ai_fields": sorted(partial_ai.keys()), - "raw_metar": _truncate_ai_text(raw_metar, 1000), - "evidence_guard": evidence_guard, - }, - } def _city_ai_response_example(unit: str) -> Dict[str, Any]: @@ -617,117 +286,3 @@ def _city_ai_stream_response_example(unit: str) -> Dict[str, Any]: "reasoning_zh": "当前观测仍贴近上午升温路径,若后续风向转为海风或云雨增强,再下修最高温中枢。", "reasoning_en": "The latest observation still fits the morning warming path; revise the daily-high center lower if later wind turns onshore or cloud/rain strengthens.", } - - -def _complete_city_ai_payload( - ai_raw: Dict[str, Any], - ai_input: Dict[str, Any], - *, - locale: str, -) -> Dict[str, Any]: - """Fill missing structured fields without marking a provider success as fallback.""" - if not isinstance(ai_raw, dict): - return _build_city_ai_fallback( - ai_input, - locale=locale, - reason="provider output was not a JSON object", - ) - fallback = _build_city_ai_fallback( - ai_input, - locale=locale, - reason="schema completion", - ) - completed: List[str] = [] - out = dict(ai_raw) - trimmed: List[str] = [] - for field in CITY_AI_REQUIRED_FIELDS: - value = out.get(field) - if isinstance(value, str) and field in _CITY_AI_TEXT_FIELDS: - clean_value = _strip_incomplete_ai_sentence(value) - if clean_value != value: - trimmed.append(field) - value = clean_value - out[field] = clean_value - if value is None or value == "" or value == []: - out[field] = fallback.get(field) - completed.append(field) - for field in ("risks_zh", "risks_en"): - value = out.get(field) - if isinstance(value, list): - cleaned_risks = [] - for item in value: - clean_item = _strip_incomplete_ai_sentence(item) - if clean_item: - cleaned_risks.append(clean_item) - if cleaned_risks != value: - trimmed.append(field) - out[field] = cleaned_risks or fallback.get(field) - meta = out.get("_polyweather_meta") - if not isinstance(meta, dict): - meta = {} - if completed: - meta["schema_completed_fields"] = completed - if trimmed: - meta["trimmed_incomplete_fields"] = sorted(set(trimmed)) - guard_meta = (fallback.get("_polyweather_meta") or {}).get("evidence_guard") - if isinstance(guard_meta, dict): - deterministic_fields: List[str] = [] - numeric_guard_active = bool( - guard_meta.get("observed_high_break") - or guard_meta.get("observed_low_break") - or guard_meta.get("observation_stale") - ) - text_guard_active = bool( - numeric_guard_active - or guard_meta.get("peak_has_passed") - or (guard_meta.get("observed_low_lag") and guard_meta.get("peak_not_started")) - ) - if numeric_guard_active: - for field in ("predicted_max", "range_low", "range_high"): - guarded_value = fallback.get(field) - if guarded_value is not None and out.get(field) != guarded_value: - out[field] = guarded_value - deterministic_fields.append(field) - if guard_meta.get("observation_stale"): - guarded_text_fields = ( - "metar_read_zh", - "metar_read_en", - "final_judgment_zh", - "final_judgment_en", - "reasoning_zh", - "reasoning_en", - "risks_zh", - "risks_en", - ) - elif text_guard_active: - guarded_text_fields = ( - "final_judgment_zh", - "final_judgment_en", - "reasoning_zh", - "reasoning_en", - "risks_zh", - "risks_en", - ) - else: - guarded_text_fields = () - for field in guarded_text_fields: - guarded_value = fallback.get(field) - if guarded_value not in (None, "", []) and out.get(field) != guarded_value: - out[field] = guarded_value - deterministic_fields.append(field) - if deterministic_fields: - meta["deterministic_guard_fields"] = sorted(set(deterministic_fields)) - meta["deterministic_guard_reason"] = { - key: guard_meta.get(key) - for key in ( - "observation_stale", - "observed_high_break", - "observed_low_break", - "observed_low_lag", - "peak_has_passed", - ) - if guard_meta.get(key) - } - out["_polyweather_meta"] = meta - return out - diff --git a/web/scan_city_ai_prompt.py b/web/scan_city_ai_prompt.py new file mode 100644 index 00000000..c94b4775 --- /dev/null +++ b/web/scan_city_ai_prompt.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import json +from typing import Any, Dict + +from web.scan_city_ai_helpers import ( + CITY_AI_REQUIRED_FIELDS, + CITY_AI_STREAM_PROVIDER_FIELDS, + _city_ai_response_example, + _city_ai_stream_response_example, +) + +SCAN_CITY_AI_PROMPT_VERSION = "city-observation-read-v6" + + +def _normalize_locale(value: Any) -> str: + text = str(value or "").strip().lower() + return "en-US" if text.startswith("en") else "zh-CN" + + +def _observation_prompt_context(ai_input: Dict[str, Any]) -> Dict[str, Any]: + observation_anchor = ai_input.get("observation_anchor") if isinstance(ai_input.get("observation_anchor"), dict) else {} + is_airport_metar = observation_anchor.get("is_airport_metar") is not False + return { + "anchor": observation_anchor, + "is_airport_metar": is_airport_metar, + "read_label_zh": str(observation_anchor.get("read_label_zh") or ("机场报文解读" if is_airport_metar else "官方观测解读")), + "instruction_zh": str(observation_anchor.get("instruction_zh") or ""), + } + + +def build_city_ai_request_json( + ai_input: Dict[str, Any], + *, + locale: str, + model: str, + max_tokens: int, +) -> Dict[str, Any]: + normalized_locale = _normalize_locale(locale) + context = _observation_prompt_context(ai_input) + observation_label_zh = context["read_label_zh"] + observation_instruction = context["instruction_zh"] + system_prompt = ( + "你是 PolyWeather 的 DeepSeek 城市最高温预测员。你必须直接阅读用户给出的城市 JSON," + "判断该城市今日最高温路径。不要写套利、交易、BUY YES/NO、价格、edge 或 Kelly。" + f"你的核心输出是:最终最高温点估计、置信区间、置信度、最终判断、{observation_label_zh}、判断依据和风险。" + "必须综合 DEB 最终融合值、全部天气模型预测、实测序列、最新观测、峰值窗口、当地时间、季节背景。" + f"{observation_instruction}" + "如果实测温度与 DEB 预测走势出现偏差,要明确说明偏差方向和可能修正。" + "你可以基于城市、时间、季节、站点位置、风向/风速、云、能见度、露点等判断风或天气是否可能影响温度路径," + "但必须使用“可能”“倾向”“需要确认”等非绝对表达。" + "观测解读必须具体:写清楚最新观测/报文时间、温度、风向风速、云量/天气、能见度或露点中与温度路径相关的因素。" + "涉及风时必须说明该风向对本城市/机场最高温路径倾向增温、降温还是中性,并给出理由;" + "不得只写“风向切换可能冷平流”,必须说明是哪一类风向或哪段风向切换可能带来冷/暖平流。" + "涉及 TAF 或云雨扰动时必须给出报文中的有效时间、BECMG/TEMPO/FM 时间窗或说明“未给出明确时间”;" + "如果没有 TAF 时间依据,不要笼统写“峰值窗口云雨扰动风险”。" + "如果峰值窗口尚未到来,不能过早下最终结论;如果峰值窗口已过或实测已创高,需要更重视最新实测。" + "所有面向用户的自然语言字段必须同时填写简体中文和英文两套内容:" + "_zh 字段写简体中文,_en 字段写英文。前端会按用户界面语言直接切换字段,不能留空。" + "risks 最多 2 条,每条必须包含触发条件或方向来源;reasoning、model_cluster_note 各 1 句,metar_read 用 1-2 句。" + "只返回 JSON object,不要 Markdown。" + ) + user_payload = { + "locale": normalized_locale, + "task": ( + "Return strict JSON with: predicted_max, range_low, range_high, unit, confidence, " + "final_judgment_zh, final_judgment_en, metar_read_zh, metar_read_en, " + "reasoning_zh, reasoning_en, risks_zh, risks_en, model_cluster_note_zh, model_cluster_note_en. " + "Fill every *_zh field in Simplified Chinese and every *_en field in English in the same response. " + "Use this exact JSON object shape; do not return an array, markdown, or prose outside JSON. " + "Keep final_judgment one short decision sentence. metar_read must explain the latest observation source " + "with report/observation time, temperature, wind direction/speed, cloud/weather/visibility/dewpoint if available. " + "For wind, explicitly say whether the current wind tends to warm, cool, or be neutral for today's high, " + "and why in local city/station context. If mentioning cold/warm advection, name the wind direction or " + "direction shift responsible. If mentioning TAF risk, include the concrete TAF time window or say no " + "explicit timing is available. model_cluster_note must state " + "how many model sources are available, whether they support DEB, and whether the sample is too sparse. " + "Keep the whole JSON compact." + ), + "required_json_keys": CITY_AI_REQUIRED_FIELDS, + "json_example": _city_ai_response_example(str(ai_input.get("temp_symbol") or "°C")), + "city_snapshot": ai_input, + } + return { + "model": model, + "temperature": 0.2, + "max_tokens": max_tokens, + "response_format": {"type": "json_object"}, + "messages": [ + {"role": "system", "content": system_prompt}, + { + "role": "user", + "content": json.dumps(user_payload, ensure_ascii=False), + }, + ], + "_polyweather_user_payload": user_payload, + "_polyweather_system_prompt": system_prompt, + } + + +def build_city_ai_empty_retry_request(request_json: Dict[str, Any]) -> Dict[str, Any]: + system_prompt = str(request_json.get("_polyweather_system_prompt") or "") + user_payload = request_json.get("_polyweather_user_payload") if isinstance(request_json.get("_polyweather_user_payload"), dict) else {} + retry_payload = { + key: value + for key, value in request_json.items() + if not key.startswith("_polyweather_") + } + retry_payload.pop("response_format", None) + retry_payload["temperature"] = 0.1 + retry_payload["messages"] = [ + { + "role": "system", + "content": ( + system_prompt + + " 这次重试必须返回一个紧凑 JSON object,不要解释,不要空回复。" + + " If you cannot infer a field, still return the field with a cautious sentence." + ), + }, + { + "role": "user", + "content": json.dumps( + { + **user_payload, + "retry_reason": "previous provider response had empty message.content", + "task": ( + str(user_payload.get("task") or "") + + " The previous response had empty content. Return only one compact JSON object now." + ), + }, + ensure_ascii=False, + ), + }, + ] + return retry_payload + + +def build_city_ai_repair_request_json( + *, + ai_input: Dict[str, Any], + locale: str, + model: str, + max_tokens: int, + previous_error: str, + previous_content: str, +) -> Dict[str, Any]: + normalized_locale = _normalize_locale(locale) + return { + "model": model, + "temperature": 0.0, + "max_tokens": min(max(max_tokens, 1200), 64000), + "response_format": {"type": "json_object"}, + "messages": [ + { + "role": "system", + "content": ( + "You repair PolyWeather AI output into one strict JSON object. " + "Do not add facts that are not present in the original city snapshot or previous assistant content. " + "Return only JSON, no markdown." + ), + }, + { + "role": "user", + "content": json.dumps( + { + "locale": normalized_locale, + "required_schema": CITY_AI_REQUIRED_FIELDS, + "previous_error": previous_error, + "previous_assistant_content": previous_content, + "city_snapshot": ai_input, + "instruction": ( + "Fill *_zh fields in Simplified Chinese and *_en fields in English; do not leave either language empty. " + "Make final_judgment one direct sentence about today's high temperature. " + "metar_read must interpret the latest observation source with report/observation time, temperature, " + "wind direction/speed, cloud/weather/visibility/dewpoint if available. State whether " + "the current wind tends to warm, cool, or stay neutral for the temperature path, and why. " + "If mentioning cold/warm advection or TAF risk, include the responsible wind direction " + "or the concrete TAF time window; otherwise say timing is not explicit. " + "model_cluster_note must mention available model count/range and whether it supports DEB. " + "Keep the JSON compact." + ), + }, + ensure_ascii=False, + ), + }, + ], + } + + +def build_city_ai_stream_request( + ai_input: Dict[str, Any], + *, + locale: str, + model: str, + max_tokens: int, +) -> Dict[str, Any]: + normalized_locale = _normalize_locale(locale) + context = _observation_prompt_context(ai_input) + is_airport_metar = context["is_airport_metar"] + role_label = "机场 METAR 解读员" if is_airport_metar else "官方观测站解读员" + read_label = context["read_label_zh"] + source_instruction = context["instruction_zh"] + system_prompt = ( + f"你是 PolyWeather 的{role_label}。" + "只返回一个紧凑 JSON object,不要 Markdown。" + f"只解读最新观测/报文对今日最高温路径的影响,必须只写 metar_read_zh、metar_read_en、reasoning_zh、reasoning_en 四个字段,便于前端快速显示{read_label};" + "最高温数值、模型一致性和风险清单由后端规则补齐,不要生成这些字段。" + f"{source_instruction}" + "观测解读必须具体说明观测/报文时间、温度、风向风速、云量/天气/能见度/露点中与温度路径相关的因素;每个字段最多 1 句。" + "涉及风时要说明当前风向对站点最高温路径倾向增温、降温还是中性,并给出理由。" + "如果 observation_anchor.is_airport_metar 为 false,不得使用 METAR、TAF、机场报文等称谓。" + "所有 *_zh 字段写简体中文,所有 *_en 字段写英文,不得留空。" + "不要写交易建议、BUY/SELL、Kelly 或套利。" + ) + return { + "model": model, + "temperature": 0.2, + "max_tokens": max_tokens, + "response_format": {"type": "json_object"}, + "stream": True, + "messages": [ + {"role": "system", "content": system_prompt}, + { + "role": "user", + "content": json.dumps( + { + "locale": normalized_locale, + "task": ( + "Return JSON keys in this exact order: metar_read_zh, metar_read_en, reasoning_zh, reasoning_en. " + "Do not return final_judgment, predicted_max, ranges, risks or model_cluster_note. Keep it compact. " + "Return exactly one JSON object and no markdown." + ), + "required_json_keys": CITY_AI_STREAM_PROVIDER_FIELDS, + "json_example": _city_ai_stream_response_example( + str(ai_input.get("temp_symbol") or "°C") + ), + "city_snapshot": ai_input, + }, + ensure_ascii=False, + ), + }, + ], + } diff --git a/web/scan_city_ai_provider.py b/web/scan_city_ai_provider.py new file mode 100644 index 00000000..df864e8b --- /dev/null +++ b/web/scan_city_ai_provider.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import json +from typing import Any, Dict + +import httpx +from loguru import logger + +from web.scan_city_ai_fallback import ( + _build_city_ai_fallback, + _complete_city_ai_payload, +) +from web.scan_city_ai_helpers import ( + _extract_ai_json_object, + _extract_provider_content, + _provider_response_meta, + _truncate_ai_text, +) +from web.scan_city_ai_prompt import ( + _normalize_locale, + build_city_ai_empty_retry_request, + build_city_ai_repair_request_json, + build_city_ai_request_json, +) + + +def _call_deepseek_city_ai( + ai_input: Dict[str, Any], + *, + locale: str = "zh-CN", + api_key: str, + base_url: str, + model: str, + max_tokens: int, + timeout_sec: int, +) -> Dict[str, Any]: + if not api_key: + raise RuntimeError("POLYWEATHER_DEEPSEEK_API_KEY is not configured") + normalized_locale = _normalize_locale(locale) + timeout = httpx.Timeout( + timeout=float(timeout_sec), + connect=min(8.0, float(timeout_sec)), + read=float(timeout_sec), + write=10.0, + pool=5.0, + ) + request_json = build_city_ai_request_json( + ai_input, + locale=normalized_locale, + model=model, + max_tokens=max_tokens, + ) + provider_request = { + key: value + for key, value in request_json.items() + if not key.startswith("_polyweather_") + } + logger.info( + "scan city AI provider request city={} locale={} input_bytes={} max_tokens={} timeout_sec={}", + ai_input.get("city"), + normalized_locale, + len(json.dumps(provider_request, ensure_ascii=False, default=str).encode("utf-8")), + request_json.get("max_tokens"), + timeout_sec, + ) + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + with httpx.Client(timeout=timeout) as client: + response = client.post( + f"{base_url}/chat/completions", + headers=headers, + json=provider_request, + ) + response.raise_for_status() + data = response.json() + content = _extract_provider_content(data) + if not str(content or "").strip(): + logger.warning( + "scan city AI provider returned empty content city={} locale={} finish_reason={} retrying_without_json_mode=true", + ai_input.get("city"), + normalized_locale, + _provider_response_meta(data).get("finish_reason"), + ) + retry_payload = build_city_ai_empty_retry_request(request_json) + response = client.post( + f"{base_url}/chat/completions", + headers=headers, + json=retry_payload, + ) + response.raise_for_status() + data = response.json() + content = _extract_provider_content(data) + try: + parsed = _extract_ai_json_object(str(content or "")) + except ValueError as exc: + preview = _truncate_ai_text(content, 700) + logger.warning( + "scan city AI provider returned non-json city={} locale={} finish_reason={} content_preview={}", + ai_input.get("city"), + normalized_locale, + _provider_response_meta(data).get("finish_reason"), + preview, + ) + repair_payload = build_city_ai_repair_request_json( + ai_input=ai_input, + locale=normalized_locale, + model=model, + max_tokens=max_tokens, + previous_error=str(exc), + previous_content=_truncate_ai_text(content, 5000), + ) + try: + repair_response = client.post( + f"{base_url}/chat/completions", + headers=headers, + json=repair_payload, + ) + repair_response.raise_for_status() + repair_data = repair_response.json() + repair_content = _extract_provider_content(repair_data) + parsed = _extract_ai_json_object(str(repair_content or "")) + if isinstance(parsed, dict): + parsed["_polyweather_meta"] = { + **_provider_response_meta(repair_data), + "repaired_from_non_json": True, + "original_finish_reason": _provider_response_meta(data).get("finish_reason"), + "original_content_preview": preview, + } + return _complete_city_ai_payload( + parsed, + ai_input, + locale=normalized_locale, + ) + except Exception as repair_exc: + logger.warning( + "scan city AI provider json repair failed city={} locale={} error={}", + ai_input.get("city"), + normalized_locale, + repair_exc, + ) + return _build_city_ai_fallback( + ai_input, + locale=normalized_locale, + reason=str(exc), + raw_content=str(content or ""), + provider_data=data, + ) + if isinstance(data, dict): + parsed["_polyweather_meta"] = _provider_response_meta(data) + return _complete_city_ai_payload( + parsed, + ai_input, + locale=normalized_locale, + ) diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index 40811f9c..676ef946 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -17,20 +17,24 @@ from loguru import logger from web.analysis_service import _analyze, _build_city_market_scan_payload from web.core import CITIES from src.data_collection.city_registry import ALIASES -from web.scan_city_ai_helpers import ( - CITY_AI_REQUIRED_FIELDS, - CITY_AI_STREAM_PROVIDER_FIELDS, +from web.scan_city_ai_fallback import ( _build_city_ai_fallback, - _city_ai_response_example, - _city_ai_stream_response_example, _complete_city_ai_payload, +) +from web.scan_city_ai_helpers import ( _extract_ai_json_object, - _extract_provider_content, _extract_provider_stream_delta, _provider_response_meta, _safe_float, _truncate_ai_text, ) +from web.scan_city_ai_prompt import ( + SCAN_CITY_AI_PROMPT_VERSION, + build_city_ai_stream_request, +) +from web.scan_city_ai_provider import ( + _call_deepseek_city_ai as _call_deepseek_city_ai_provider, +) _SCAN_TERMINAL_CACHE_LOCK = threading.Lock() _SCAN_TERMINAL_CACHE: Dict[str, Dict[str, Any]] = {} @@ -118,9 +122,6 @@ SCAN_CITY_AI_STREAM_MAX_TOKENS = _env_int( min_value=300, max_value=64000, ) -SCAN_CITY_AI_PROMPT_VERSION = "city-observation-read-v6" - - def _safe_int(value: Any, default: int) -> int: try: @@ -1129,248 +1130,14 @@ def _build_city_ai_prompt(data: Dict[str, Any]) -> Dict[str, Any]: def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") -> Dict[str, Any]: - api_key = str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip() - if not api_key: - raise RuntimeError("POLYWEATHER_DEEPSEEK_API_KEY is not configured") - normalized_locale = _normalize_locale(locale) - observation_anchor = ai_input.get("observation_anchor") if isinstance(ai_input.get("observation_anchor"), dict) else {} - is_airport_metar = observation_anchor.get("is_airport_metar") is not False - observation_label_zh = str(observation_anchor.get("read_label_zh") or ("机场报文解读" if is_airport_metar else "官方观测解读")) - observation_instruction = str(observation_anchor.get("instruction_zh") or "") - - system_prompt = ( - "你是 PolyWeather 的 DeepSeek 城市最高温预测员。你必须直接阅读用户给出的城市 JSON," - "判断该城市今日最高温路径。不要写套利、交易、BUY YES/NO、价格、edge 或 Kelly。" - f"你的核心输出是:最终最高温点估计、置信区间、置信度、最终判断、{observation_label_zh}、判断依据和风险。" - "必须综合 DEB 最终融合值、全部天气模型预测、实测序列、最新观测、峰值窗口、当地时间、季节背景。" - f"{observation_instruction}" - "如果实测温度与 DEB 预测走势出现偏差,要明确说明偏差方向和可能修正。" - "你可以基于城市、时间、季节、站点位置、风向/风速、云、能见度、露点等判断风或天气是否可能影响温度路径," - "但必须使用“可能”“倾向”“需要确认”等非绝对表达。" - "观测解读必须具体:写清楚最新观测/报文时间、温度、风向风速、云量/天气、能见度或露点中与温度路径相关的因素。" - "涉及风时必须说明该风向对本城市/机场最高温路径倾向增温、降温还是中性,并给出理由;" - "不得只写“风向切换可能冷平流”,必须说明是哪一类风向或哪段风向切换可能带来冷/暖平流。" - "涉及 TAF 或云雨扰动时必须给出报文中的有效时间、BECMG/TEMPO/FM 时间窗或说明“未给出明确时间”;" - "如果没有 TAF 时间依据,不要笼统写“峰值窗口云雨扰动风险”。" - "如果峰值窗口尚未到来,不能过早下最终结论;如果峰值窗口已过或实测已创高,需要更重视最新实测。" - "所有面向用户的自然语言字段必须同时填写简体中文和英文两套内容:" - "_zh 字段写简体中文,_en 字段写英文。前端会按用户界面语言直接切换字段,不能留空。" - "risks 最多 2 条,每条必须包含触发条件或方向来源;reasoning、model_cluster_note 各 1 句,metar_read 用 1-2 句。" - "只返回 JSON object,不要 Markdown。" - ) - user_payload = { - "locale": normalized_locale, - "task": ( - "Return strict JSON with: predicted_max, range_low, range_high, unit, confidence, " - "final_judgment_zh, final_judgment_en, metar_read_zh, metar_read_en, " - "reasoning_zh, reasoning_en, risks_zh, risks_en, model_cluster_note_zh, model_cluster_note_en. " - "Fill every *_zh field in Simplified Chinese and every *_en field in English in the same response. " - "Use this exact JSON object shape; do not return an array, markdown, or prose outside JSON. " - "Keep final_judgment one short decision sentence. metar_read must explain the latest observation source " - "with report/observation time, temperature, wind direction/speed, cloud/weather/visibility/dewpoint if available. " - "For wind, explicitly say whether the current wind tends to warm, cool, or be neutral for today's high, " - "and why in local city/station context. If mentioning cold/warm advection, name the wind direction or " - "direction shift responsible. If mentioning TAF risk, include the concrete TAF time window or say no " - "explicit timing is available. model_cluster_note must state " - "how many model sources are available, whether they support DEB, and whether the sample is too sparse. " - "Keep the whole JSON compact." - ), - "required_json_keys": CITY_AI_REQUIRED_FIELDS, - "json_example": _city_ai_response_example(str(ai_input.get("temp_symbol") or "°C")), - "city_snapshot": ai_input, - } - timeout = httpx.Timeout( - timeout=float(SCAN_CITY_AI_TIMEOUT_SEC), - connect=min(8.0, float(SCAN_CITY_AI_TIMEOUT_SEC)), - read=float(SCAN_CITY_AI_TIMEOUT_SEC), - write=10.0, - pool=5.0, - ) - request_json = { - "model": SCAN_CITY_AI_MODEL, - "temperature": 0.2, - "max_tokens": SCAN_CITY_AI_MAX_TOKENS, - "response_format": {"type": "json_object"}, - "messages": [ - {"role": "system", "content": system_prompt}, - { - "role": "user", - "content": json.dumps(user_payload, ensure_ascii=False), - }, - ], - } - logger.info( - "scan city AI provider request city={} locale={} input_bytes={} max_tokens={} timeout_sec={}", - ai_input.get("city"), - normalized_locale, - len(json.dumps(request_json, ensure_ascii=False, default=str).encode("utf-8")), - request_json.get("max_tokens"), - SCAN_CITY_AI_TIMEOUT_SEC, - ) - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - } - with httpx.Client(timeout=timeout) as client: - response = client.post( - f"{SCAN_AI_BASE_URL}/chat/completions", - headers=headers, - json=request_json, - ) - response.raise_for_status() - data = response.json() - content = _extract_provider_content(data) - if not str(content or "").strip(): - logger.warning( - "scan city AI provider returned empty content city={} locale={} finish_reason={} retrying_without_json_mode=true", - ai_input.get("city"), - normalized_locale, - _provider_response_meta(data).get("finish_reason"), - ) - retry_payload = dict(request_json) - retry_payload.pop("response_format", None) - retry_payload["temperature"] = 0.1 - retry_payload["messages"] = [ - { - "role": "system", - "content": ( - system_prompt - + " 这次重试必须返回一个紧凑 JSON object,不要解释,不要空回复。" - + " If you cannot infer a field, still return the field with a cautious sentence." - ), - }, - { - "role": "user", - "content": json.dumps( - { - **user_payload, - "retry_reason": "previous provider response had empty message.content", - "task": ( - user_payload["task"] - + " The previous response had empty content. Return only one compact JSON object now." - ), - }, - ensure_ascii=False, - ), - }, - ] - response = client.post( - f"{SCAN_AI_BASE_URL}/chat/completions", - headers=headers, - json=retry_payload, - ) - response.raise_for_status() - data = response.json() - content = _extract_provider_content(data) - try: - parsed = _extract_ai_json_object(str(content or "")) - except ValueError as exc: - preview = _truncate_ai_text(content, 700) - logger.warning( - "scan city AI provider returned non-json city={} locale={} finish_reason={} content_preview={}", - ai_input.get("city"), - normalized_locale, - _provider_response_meta(data).get("finish_reason"), - preview, - ) - repair_payload = { - "model": SCAN_CITY_AI_MODEL, - "temperature": 0.0, - "max_tokens": min(max(SCAN_CITY_AI_MAX_TOKENS, 1200), 64000), - "response_format": {"type": "json_object"}, - "messages": [ - { - "role": "system", - "content": ( - "You repair PolyWeather AI output into one strict JSON object. " - "Do not add facts that are not present in the original city snapshot or previous assistant content. " - "Return only JSON, no markdown." - ), - }, - { - "role": "user", - "content": json.dumps( - { - "locale": normalized_locale, - "required_schema": [ - "predicted_max", - "range_low", - "range_high", - "unit", - "confidence", - "final_judgment_zh", - "final_judgment_en", - "metar_read_zh", - "metar_read_en", - "reasoning_zh", - "reasoning_en", - "risks_zh", - "risks_en", - "model_cluster_note_zh", - "model_cluster_note_en", - ], - "previous_error": str(exc), - "previous_assistant_content": _truncate_ai_text(content, 5000), - "city_snapshot": ai_input, - "instruction": ( - "Fill *_zh fields in Simplified Chinese and *_en fields in English; do not leave either language empty. " - "Make final_judgment one direct sentence about today's high temperature. " - "metar_read must interpret the latest observation source with report/observation time, temperature, " - "wind direction/speed, cloud/weather/visibility/dewpoint if available. State whether " - "the current wind tends to warm, cool, or stay neutral for the temperature path, and why. " - "If mentioning cold/warm advection or TAF risk, include the responsible wind direction " - "or the concrete TAF time window; otherwise say timing is not explicit. " - "model_cluster_note must mention available model count/range and whether it supports DEB. " - "Keep the JSON compact." - ), - }, - ensure_ascii=False, - ), - }, - ], - } - try: - repair_response = client.post( - f"{SCAN_AI_BASE_URL}/chat/completions", - headers=headers, - json=repair_payload, - ) - repair_response.raise_for_status() - repair_data = repair_response.json() - repair_content = _extract_provider_content(repair_data) - parsed = _extract_ai_json_object(str(repair_content or "")) - if isinstance(parsed, dict): - parsed["_polyweather_meta"] = { - **_provider_response_meta(repair_data), - "repaired_from_non_json": True, - "original_finish_reason": _provider_response_meta(data).get("finish_reason"), - "original_content_preview": preview, - } - return _complete_city_ai_payload( - parsed, - ai_input, - locale=normalized_locale, - ) - except Exception as repair_exc: - logger.warning( - "scan city AI provider json repair failed city={} locale={} error={}", - ai_input.get("city"), - normalized_locale, - repair_exc, - ) - return _build_city_ai_fallback( - ai_input, - locale=normalized_locale, - reason=str(exc), - raw_content=str(content or ""), - provider_data=data, - ) - if isinstance(data, dict): - parsed["_polyweather_meta"] = _provider_response_meta(data) - return _complete_city_ai_payload( - parsed, + return _call_deepseek_city_ai_provider( ai_input, - locale=normalized_locale, + locale=locale, + api_key=str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip(), + base_url=SCAN_AI_BASE_URL, + model=SCAN_CITY_AI_MODEL, + max_tokens=SCAN_CITY_AI_MAX_TOKENS, + timeout_sec=SCAN_CITY_AI_TIMEOUT_SEC, ) @@ -1431,53 +1198,12 @@ def _build_city_ai_stream_request( *, locale: str, ) -> Dict[str, Any]: - normalized_locale = _normalize_locale(locale) - observation_anchor = ai_input.get("observation_anchor") if isinstance(ai_input.get("observation_anchor"), dict) else {} - is_airport_metar = observation_anchor.get("is_airport_metar") is not False - role_label = "机场 METAR 解读员" if is_airport_metar else "官方观测站解读员" - read_label = str(observation_anchor.get("read_label_zh") or ("机场报文解读" if is_airport_metar else "官方观测解读")) - source_instruction = str(observation_anchor.get("instruction_zh") or "") - system_prompt = ( - f"你是 PolyWeather 的{role_label}。" - "只返回一个紧凑 JSON object,不要 Markdown。" - f"只解读最新观测/报文对今日最高温路径的影响,必须只写 metar_read_zh、metar_read_en、reasoning_zh、reasoning_en 四个字段,便于前端快速显示{read_label};" - "最高温数值、模型一致性和风险清单由后端规则补齐,不要生成这些字段。" - f"{source_instruction}" - "观测解读必须具体说明观测/报文时间、温度、风向风速、云量/天气/能见度/露点中与温度路径相关的因素;每个字段最多 1 句。" - "涉及风时要说明当前风向对站点最高温路径倾向增温、降温还是中性,并给出理由。" - "如果 observation_anchor.is_airport_metar 为 false,不得使用 METAR、TAF、机场报文等称谓。" - "所有 *_zh 字段写简体中文,所有 *_en 字段写英文,不得留空。" - "不要写交易建议、BUY/SELL、Kelly 或套利。" + return build_city_ai_stream_request( + ai_input, + locale=locale, + model=SCAN_CITY_AI_MODEL, + max_tokens=SCAN_CITY_AI_STREAM_MAX_TOKENS, ) - return { - "model": SCAN_CITY_AI_MODEL, - "temperature": 0.2, - "max_tokens": SCAN_CITY_AI_STREAM_MAX_TOKENS, - "response_format": {"type": "json_object"}, - "stream": True, - "messages": [ - {"role": "system", "content": system_prompt}, - { - "role": "user", - "content": json.dumps( - { - "locale": normalized_locale, - "task": ( - "Return JSON keys in this exact order: metar_read_zh, metar_read_en, reasoning_zh, reasoning_en. " - "Do not return final_judgment, predicted_max, ranges, risks or model_cluster_note. Keep it compact. " - "Return exactly one JSON object and no markdown." - ), - "required_json_keys": CITY_AI_STREAM_PROVIDER_FIELDS, - "json_example": _city_ai_stream_response_example( - str(ai_input.get("temp_symbol") or "°C") - ), - "city_snapshot": ai_input, - }, - ensure_ascii=False, - ), - }, - ], - } def _cache_city_ai_payload(