From 6c18bead954c259af4367d921afb246a46682613 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 21:42:42 +0000 Subject: [PATCH 1/2] fix(native): place market chart date ticks like d3 scaleTime ticks Swift Charts' automatic date marks picked coarser steps than the web AreaChart's scaleTime().ticks(n), so the market lookup chart showed far fewer dates. TimeAxisTicks picks d3's calendar interval nearest span/count and aligns it as d3-time does (two-day steps restart each month). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018QJtaMxiuFABVL2LiMKy5m --- .../Screens/Shared/TimeAxisTicks.swift | 121 ++++++++++++++++++ .../ResearchMarketChartView.swift | 9 +- .../TimeAxisTicksTests.swift | 39 ++++++ 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 apps/native/Sources/VisionNativeCore/Screens/Shared/TimeAxisTicks.swift create mode 100644 apps/native/Tests/VisionNativeCoreTests/TimeAxisTicksTests.swift diff --git a/apps/native/Sources/VisionNativeCore/Screens/Shared/TimeAxisTicks.swift b/apps/native/Sources/VisionNativeCore/Screens/Shared/TimeAxisTicks.swift new file mode 100644 index 000000000..11cf5fdd1 --- /dev/null +++ b/apps/native/Sources/VisionNativeCore/Screens/Shared/TimeAxisTicks.swift @@ -0,0 +1,121 @@ +import Foundation + +/// d3's `scaleTime().ticks(count)` (d3-time `ticks`): calendar-aligned tick dates at the +/// interval whose length is closest to `span / count`, in local time. Swift Charts' +/// `.automatic(desiredCount:)` picks coarser intervals, which left the market chart with far +/// fewer date labels than the web `AreaChart`. +public enum TimeAxisTicks { + private struct Interval { + let component: Calendar.Component + let step: Int + let seconds: Double + } + + private static let minute = 60.0 + private static let hour = 3_600.0 + private static let day = 86_400.0 + private static let week = 604_800.0 + private static let month = 2_592_000.0 + private static let year = 31_536_000.0 + + /// d3-time's `tickIntervals` from one minute up (the charts never span less). + private static let intervals: [Interval] = [ + Interval(component: .minute, step: 1, seconds: minute), + Interval(component: .minute, step: 5, seconds: 5 * minute), + Interval(component: .minute, step: 15, seconds: 15 * minute), + Interval(component: .minute, step: 30, seconds: 30 * minute), + Interval(component: .hour, step: 1, seconds: hour), + Interval(component: .hour, step: 3, seconds: 3 * hour), + Interval(component: .hour, step: 6, seconds: 6 * hour), + Interval(component: .hour, step: 12, seconds: 12 * hour), + Interval(component: .day, step: 1, seconds: day), + Interval(component: .day, step: 2, seconds: 2 * day), + Interval(component: .weekOfYear, step: 1, seconds: week), + Interval(component: .month, step: 1, seconds: month), + Interval(component: .month, step: 3, seconds: 3 * month), + Interval(component: .year, step: 1, seconds: year), + ] + + /// The tick dates between `start` and `end` (both included when they fall on a tick). + public static func values(from start: Date, to end: Date, count: Int, calendar: Calendar = .current) -> [Date] { + guard count > 0, end > start else { return [] } + let target = end.timeIntervalSince(start) / Double(count) + let interval: Interval + if let index = intervals.firstIndex(where: { $0.seconds > target }) { + if index == 0 { + interval = intervals[0] + } else { + // d3: the neighbour whose length is closer to the target, by ratio. + let lower = intervals[index - 1] + let upper = intervals[index] + interval = target / lower.seconds < upper.seconds / target ? lower : upper + } + } else { + // Beyond a year: whole years at a nice step (d3 `tickStep` over years). + let years = target / year + interval = Interval(component: .year, step: niceStep(years), seconds: year) + } + return ticks(from: start, to: end, interval: interval, calendar: calendar) + } + + private static func niceStep(_ value: Double) -> Int { + guard value > 1 else { return 1 } + let power = pow(10, (log10(value)).rounded(.down)) + let error = value / power + let factor: Double = error >= 7.07 ? 10 : error >= 3.16 ? 5 : error >= 1.41 ? 2 : 1 + return max(1, Int(factor * power)) + } + + private static func ticks(from start: Date, to end: Date, interval: Interval, calendar: Calendar) -> [Date] { + guard var tick = alignedStart(start, interval: interval, calendar: calendar) else { return [] } + var result: [Date] = [] + var guardCount = 0 + // d3's `timeDay.every(2)` keeps days whose day-of-month is `1 + k·step`, so the count + // restarts on the 1st of each month: step one day at a time and filter. + let filtersDays = interval.component == .day && interval.step > 1 + let advance = filtersDays ? 1 : interval.step + while tick <= end, guardCount < 10_000 { + let keeps = !filtersDays || (calendar.component(.day, from: tick) - 1) % interval.step == 0 + if tick >= start, keeps { result.append(tick) } + guard let next = calendar.date(byAdding: interval.component, value: advance, to: tick) else { break } + tick = next + guardCount += 1 + } + return result + } + + /// The interval boundary at or before `date`, aligned as d3's `every(step)` aligns: minutes, + /// hours, months and years on multiples of the step, days on day-of-month `1 + k·step`, + /// weeks on the calendar's first weekday. + private static func alignedStart(_ date: Date, interval: Interval, calendar: Calendar) -> Date? { + switch interval.component { + case .minute, .hour: + let parts = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date) + var aligned = parts + if interval.component == .minute { + aligned.minute = (parts.minute ?? 0) / interval.step * interval.step + } else { + aligned.minute = 0 + aligned.hour = (parts.hour ?? 0) / interval.step * interval.step + } + return calendar.date(from: aligned) + case .day: + let parts = calendar.dateComponents([.year, .month, .day], from: date) + var aligned = parts + aligned.day = ((parts.day ?? 1) - 1) / interval.step * interval.step + 1 + return calendar.date(from: aligned) + case .weekOfYear: + return calendar.dateInterval(of: .weekOfYear, for: date)?.start + case .month: + let parts = calendar.dateComponents([.year, .month], from: date) + var aligned = parts + aligned.month = ((parts.month ?? 1) - 1) / interval.step * interval.step + 1 + aligned.day = 1 + return calendar.date(from: aligned) + default: + let parts = calendar.dateComponents([.year], from: date) + let year = (parts.year ?? 1970) / interval.step * interval.step + return calendar.date(from: DateComponents(year: year, month: 1, day: 1)) + } + } +} diff --git a/apps/native/Sources/VisionNativeUI/ResearchMarketChartView.swift b/apps/native/Sources/VisionNativeUI/ResearchMarketChartView.swift index d87aa56e1..090664035 100644 --- a/apps/native/Sources/VisionNativeUI/ResearchMarketChartView.swift +++ b/apps/native/Sources/VisionNativeUI/ResearchMarketChartView.swift @@ -50,7 +50,14 @@ struct ResearchMarketChartView: View { } .chartXAxis { // AreaChart's bottom axis: `numTicks={Math.max(2, Math.floor(innerWidth / 90))}`. - AxisMarks(values: .automatic(desiredCount: max(2, Int(chartWidth / 90)))) { value in + // d3 `scaleTime().ticks(n)` dates: `.automatic(desiredCount:)` picked coarser steps. + AxisMarks( + values: plot.times.first.flatMap { first in + plot.times.last.map { last in + TimeAxisTicks.values(from: first, to: last, count: max(2, Int(chartWidth / 90))) + } + } ?? [] + ) { value in AxisTick() AxisValueLabel { if let date = value.as(Date.self) { diff --git a/apps/native/Tests/VisionNativeCoreTests/TimeAxisTicksTests.swift b/apps/native/Tests/VisionNativeCoreTests/TimeAxisTicksTests.swift new file mode 100644 index 000000000..7d30dab91 --- /dev/null +++ b/apps/native/Tests/VisionNativeCoreTests/TimeAxisTicksTests.swift @@ -0,0 +1,39 @@ +import Foundation +import Testing +@testable import VisionNativeCore + +/// The market chart's date ticks follow d3 `scaleTime().ticks(n)`. +struct TimeAxisTicksTests { + private let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Europe/Brussels")! + return calendar + }() + + private func date(_ text: String) -> Date { ISO8601DateFormatter().date(from: text)! } + + @Test func oneYearAtNineTicksIsMonthly() { + let ticks = TimeAxisTicks.values( + from: date("2025-11-14T00:00:00Z"), to: date("2026-11-14T00:00:00Z"), count: 9, calendar: calendar) + #expect(ticks.count == 12) + #expect(ticks.allSatisfy { calendar.component(.day, from: $0) == 1 }) + } + + @Test func oneMonthAtNineTicksIsEveryOtherDay() { + let ticks = TimeAxisTicks.values( + from: date("2026-10-14T00:00:00Z"), to: date("2026-11-14T00:00:00Z"), count: 9, calendar: calendar) + #expect(ticks.count == 16) + #expect(ticks.allSatisfy { (calendar.component(.day, from: $0) - 1) % 2 == 0 }) + } + + @Test func decadesStepByNiceYears() { + let ticks = TimeAxisTicks.values( + from: date("2000-01-01T00:00:00Z"), to: date("2026-11-14T00:00:00Z"), count: 9, calendar: calendar) + #expect(ticks.map { calendar.component(.year, from: $0) } == Array(stride(from: 2002, through: 2026, by: 2))) + } + + @Test func emptyRangeHasNoTicks() { + let now = date("2026-11-14T00:00:00Z") + #expect(TimeAxisTicks.values(from: now, to: now, count: 9, calendar: calendar).isEmpty) + } +} From 52c57721799103d72bc6beb1fa173014fb78e5ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 21:42:42 +0000 Subject: [PATCH 2/2] fix(native): draw symbol results and the scorecard like the web SymbolSearchResultItem: type-body ticker and name, outline badge for the type, px-3 py-2.5 rows. ScorecardPanel: grade badge, severity count pills and the concerns as an inset list with severity glyphs, value over benchmark. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018QJtaMxiuFABVL2LiMKy5m --- .../ResearchMarketDetailViews.swift | 109 ++++++++++++------ .../VisionNativeUI/ResearchUIComponents.swift | 25 ++-- 2 files changed, 90 insertions(+), 44 deletions(-) diff --git a/apps/native/Sources/VisionNativeUI/ResearchMarketDetailViews.swift b/apps/native/Sources/VisionNativeUI/ResearchMarketDetailViews.swift index be776f687..c605f6540 100644 --- a/apps/native/Sources/VisionNativeUI/ResearchMarketDetailViews.swift +++ b/apps/native/Sources/VisionNativeUI/ResearchMarketDetailViews.swift @@ -125,55 +125,92 @@ struct ResearchMarketScorecardView: View { @Environment(\.visionTheme) private var theme private var strings: any Localizing { shell.strings } var body: some View { + // ScorecardPanel (ResearchScorecard.tsx): the grade badge, the evaluated count and the + // severity count pills, then the worst-first concerns as an inset `List` of `ListRow`s. VStack(alignment: .leading, spacing: 12) { if scorecard.evaluated == 0 { - Text(strings.t("research.scorecard.noData")).foregroundStyle(theme.secondaryLabel) + Text(strings.t("research.scorecard.noData")).font(Self.callout).foregroundStyle(theme.secondaryLabel) + .padding(.vertical, 8) } else { - HStack { - Text(strings.t("research.scorecard.grade." + scorecard.grade)).font(.headline) - if let score = scorecard.score { - Text(ResearchMarketDisplay.number(score, shell: shell, digits: 0)).monospacedDigit() - } - Text(strings.t("research.scorecard.evaluated", ["count": String(scorecard.evaluated)])).font( - .caption - ) - .foregroundStyle(theme.secondaryLabel) - Spacer() - ForEach(["risk", "warn", "caution"], id: \.self) { severity in - if let count = scorecard.counts[severity], count > 0 { - Text(strings.t("research.scorecard.severity." + severity) + " " + String(count)).font( - .caption - ).foregroundStyle(tone(severity)) + HStack(spacing: 12) { + VisionBadge(text: gradeText, tone: gradeTone) + Text(strings.t("research.scorecard.evaluated", ["count": String(scorecard.evaluated)])) + .font(Self.footnote).foregroundStyle(theme.secondaryLabel) + Spacer(minLength: 0) + HStack(spacing: 6) { + ForEach(["risk", "warn", "caution"], id: \.self) { severity in + if let count = scorecard.counts[severity], count > 0 { + VisionBadge( + text: String(count) + " " + strings.t("research.scorecard.severity." + severity), + tone: severity == "risk" ? .destructive : .warning, small: true) + } } } } let concerns = scorecard.flags.filter { $0.severity != "ok" } if concerns.isEmpty { - Label(strings.t("research.scorecard.allClear"), systemImage: "checkmark.circle").foregroundStyle( - theme.success) - } - ForEach(concerns) { flag in - VStack(alignment: .leading, spacing: 5) { - HStack { - Image( - systemName: flag.severity == "risk" - ? "exclamationmark.octagon" : "exclamationmark.triangle" - ).foregroundStyle(tone(flag.severity)).accessibilityLabel( - strings.t("research.scorecard.severity." + flag.severity)) - Text(strings.t("research.metric." + flag.metric)).font(.callout.weight(.medium)) - Spacer() - Text(flagValue(flag)).monospacedDigit() + Label(strings.t("research.scorecard.allClear"), systemImage: "checkmark.circle") + .font(Self.callout).foregroundStyle(theme.success) + } else { + InsightsInsetList { + ForEach(Array(concerns.enumerated()), id: \.element.id) { index, flag in + if index > 0 { InsightsInsetDivider() } + concernRow(flag) } - let key = "research.scorecard.reason." + flag.reasonKey - Text(strings.t(key) == key ? flag.reason : strings.t(key)).font(.caption).foregroundStyle( - theme.secondaryLabel) - Text(strings.t("research.scorecard.benchmark", ["value": localizedBenchmark(flag.benchmark)])) - .font(.caption).foregroundStyle(theme.secondaryLabel) - }.padding(.vertical, 7) + } } } } } + private static var footnote: Font { VisionFonts.font(DesignTokens.Typography.footnote, relativeTo: .footnote) } + private static var callout: Font { VisionFonts.font(DesignTokens.Typography.callout, relativeTo: .callout) } + /// `ScorecardGradeBadge`: the score, then the grade. + private var gradeText: String { + let grade = strings.t("research.scorecard.grade." + scorecard.grade) + guard let score = scorecard.score else { return grade } + return ResearchMarketDisplay.number(score, shell: shell, digits: 0) + " " + grade + } + private var gradeTone: BadgeTone { + switch scorecard.grade { + case "strong", "healthy": return .success + case "mixed", "weak": return .warning + case "poor": return .destructive + default: return .muted + } + } + /// A concern's `ListRow`: the severity glyph, the metric over its reason, and the value over + /// its benchmark at the trailing edge. + private func concernRow(_ flag: ResearchScorecardFlag) -> some View { + let key = "research.scorecard.reason." + flag.reasonKey + return HStack(alignment: .center, spacing: 12) { + Image(systemName: Self.icon(flag.severity)) + .font(.system(size: 15)).foregroundStyle(tone(flag.severity)) + .frame(width: 28, height: 28) + .accessibilityLabel(strings.t("research.scorecard.severity." + flag.severity)) + VStack(alignment: .leading, spacing: 0) { + Text(strings.t("research.metric." + flag.metric)).lineLimit(1).foregroundStyle(theme.foreground) + Text(strings.t(key) == key ? flag.reason : strings.t(key)).font(Self.footnote).lineLimit(1) + .foregroundStyle(theme.secondaryLabel) + } + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 4) { + Text(flagValue(flag)).monospacedDigit().foregroundStyle(theme.foreground) + Text(strings.t("research.scorecard.benchmark", ["value": localizedBenchmark(flag.benchmark)])) + .foregroundStyle(theme.secondaryLabel) + } + .font(Self.footnote) + } + .padding(.horizontal, 16).padding(.vertical, 10) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + } + /// lucide `CheckCircle2`, `TriangleAlert`, `AlertTriangle` and `ShieldAlert`. + private static func icon(_ severity: String) -> String { + switch severity { + case "ok": return "checkmark.circle" + case "risk": return "exclamationmark.shield" + default: return "exclamationmark.triangle" + } + } private func tone(_ severity: String) -> Color { severity == "risk" ? theme.destructive : severity == "ok" ? theme.success : theme.warning } diff --git a/apps/native/Sources/VisionNativeUI/ResearchUIComponents.swift b/apps/native/Sources/VisionNativeUI/ResearchUIComponents.swift index 26ddcc280..908d9de1f 100644 --- a/apps/native/Sources/VisionNativeUI/ResearchUIComponents.swift +++ b/apps/native/Sources/VisionNativeUI/ResearchUIComponents.swift @@ -181,19 +181,28 @@ struct ResearchSymbolSearchView: View { Image(systemName: leadingSymbol).font(.system(size: 12, weight: .medium)) .foregroundStyle(theme.secondaryLabel).frame(width: 14).accessibilityHidden(true) } - Text(item.symbol ?? "—").font(.callout.monospaced().weight(.medium)).frame( - minWidth: 64, alignment: .leading) - Text(item.name ?? "").font(.callout).foregroundStyle(theme.secondaryLabel).lineLimit(1) + // SymbolSearchResultItem: mono `type-body` ticker (`min-w-[5rem]`), the name in + // `type-body` secondary, the type as an outline `Badge size="sm"` and the exchange in + // `type-footnote`. + Text(item.symbol ?? "—") + .font(.system(size: CGFloat(DesignTokens.Typography.body.size), weight: .medium, design: .monospaced)) + .frame(minWidth: 80, alignment: .leading) + Text(item.name ?? "").font(VisionFonts.font(DesignTokens.Typography.body, relativeTo: .body)) + .foregroundStyle(theme.secondaryLabel).lineLimit(1) Spacer(minLength: 0) if let type = item.type, !type.isEmpty { - Text(type).font(.caption).padding(.horizontal, 6).padding(.vertical, 3) - .overlay(RoundedRectangle(cornerRadius: 5).stroke(theme.border)) + VisionBadge(text: type, tone: .outline, small: true).fixedSize() } if let exchange = item.exchange, !exchange.isEmpty { - Text(exchange).font(.caption).foregroundStyle(theme.secondaryLabel) + Text(exchange).font(VisionFonts.font(DesignTokens.Typography.footnote, relativeTo: .footnote)) + .foregroundStyle(theme.secondaryLabel).fixedSize() } - }.padding(10).frame(maxWidth: .infinity, alignment: .leading) - .background(selected ? theme.foreground.opacity(0.06) : .clear, in: RoundedRectangle(cornerRadius: DesignTokens.Radius.md)) + } + // `px-3 py-2.5 rounded-control`, `bg-foreground/[0.06]` while active. + .padding(.horizontal, 12).padding(.vertical, 10).frame(maxWidth: .infinity, alignment: .leading) + .background( + selected ? theme.foreground.opacity(0.06) : .clear, + in: RoundedRectangle(cornerRadius: DesignTokens.Radius.control)) } private func move(_ direction: Int) -> KeyPress.Result { guard isOpen, !isUnavailable, !navigable.isEmpty else { return .ignored }