From 450644722361ce6bdef74366423745d0e65cba9e Mon Sep 17 00:00:00 2001 From: everpcpc Date: Tue, 7 Jul 2026 17:55:20 +0800 Subject: [PATCH 1/2] feat(stats): show reading pages heatmap Replace the reading time line chart with a GitHub-style pages-read heatmap. Aggregate completed-book progress by page count, default the stats range to 90 days, and move reading stats cache to a new key so old hourly data is not reused. --- KMReader/Core/Storage/AppConfig.swift | 4 +- .../Models/ReadingPagesHeatmapWeek.swift | 11 + .../Services/ReadingStatsService.swift | 31 +- .../ViewModels/ReadingStatsViewModel.swift | 213 ++++------ .../Views/ReadingPagesHeatmapView.swift | 142 +++++++ .../Views/ServerReadingStatsView.swift | 175 +------- KMReader/Localizable.xcstrings | 384 ------------------ 7 files changed, 266 insertions(+), 694 deletions(-) create mode 100644 KMReader/Features/History/Models/ReadingPagesHeatmapWeek.swift create mode 100644 KMReader/Features/History/Views/ReadingPagesHeatmapView.swift diff --git a/KMReader/Core/Storage/AppConfig.swift b/KMReader/Core/Storage/AppConfig.swift index d544a325..8e032f04 100644 --- a/KMReader/Core/Storage/AppConfig.swift +++ b/KMReader/Core/Storage/AppConfig.swift @@ -1130,7 +1130,7 @@ enum AppConfig { static nonisolated var readingStatsCache: ReadingStatsCache { get { - if let stored = UserDefaults.standard.string(forKey: "readingStatsCache"), + if let stored = UserDefaults.standard.string(forKey: "readingStatsCacheV2"), let cache = ReadingStatsCache(rawValue: stored) { return cache @@ -1138,7 +1138,7 @@ enum AppConfig { return ReadingStatsCache() } set { - UserDefaults.standard.set(newValue.rawValue, forKey: "readingStatsCache") + UserDefaults.standard.set(newValue.rawValue, forKey: "readingStatsCacheV2") } } diff --git a/KMReader/Features/History/Models/ReadingPagesHeatmapWeek.swift b/KMReader/Features/History/Models/ReadingPagesHeatmapWeek.swift new file mode 100644 index 00000000..eccf3e85 --- /dev/null +++ b/KMReader/Features/History/Models/ReadingPagesHeatmapWeek.swift @@ -0,0 +1,11 @@ +// +// ReadingPagesHeatmapWeek.swift +// +// + +import Foundation + +nonisolated struct ReadingPagesHeatmapWeek: Equatable, Sendable, Identifiable { + let id: String + let days: [ReadingStatsTimePoint?] +} diff --git a/KMReader/Features/History/Services/ReadingStatsService.swift b/KMReader/Features/History/Services/ReadingStatsService.swift index 6f5e667a..a62bdfea 100644 --- a/KMReader/Features/History/Services/ReadingStatsService.swift +++ b/KMReader/Features/History/Services/ReadingStatsService.swift @@ -56,12 +56,10 @@ nonisolated enum ReadingStatsService { let completedBooks = filteredBooks.filter(\.isCompleted) let totalPagesRead = completedBooks.reduce(0.0) { partial, book in - partial + Double(book.readProgress?.page ?? 0) + partial + Double(readPageCount(for: book)) } let averagePagesPerBook = completedBooks.isEmpty ? 0 : (totalPagesRead / Double(completedBooks.count)).rounded() - let estimatedReadingHours = (totalPagesRead / 2 / 60).rounded() - let readDates = completedBooks.compactMap { $0.readProgress?.readDate } let lastReadDate = readDates.max() let uniqueReadingDays = Set(readDates.map(Self.dayKey)) @@ -73,7 +71,7 @@ nonisolated enum ReadingStatsService { totalPagesRead: totalPagesRead, averagePagesPerBook: averagePagesPerBook, readingDays: Double(uniqueReadingDays.count), - estimatedReadingHours: estimatedReadingHours, + estimatedReadingHours: 0, lastReadAt: lastReadDate.map(Self.isoDateTime) ) @@ -85,7 +83,7 @@ nonisolated enum ReadingStatsService { let dailyDistribution = buildDailyDistribution(completedBooks: completedBooks) let hourlyDistribution = buildHourlyDistribution(completedBooks: completedBooks) - let readingTimeSeries = buildReadingTimeSeries(completedBooks: completedBooks) + let dailyPagesSeries = buildDailyPagesSeries(completedBooks: completedBooks) let dimensions = buildDimensions(completedBooks: completedBooks, readSeries: readSeries) @@ -94,7 +92,7 @@ nonisolated enum ReadingStatsService { statusDistribution: statusDistribution, dailyDistribution: dailyDistribution, hourlyDistribution: hourlyDistribution, - readingTimeSeries: readingTimeSeries, + readingTimeSeries: dailyPagesSeries, topAuthors: dimensions.topAuthors, topGenres: dimensions.topGenres, topTags: dimensions.topTags, @@ -153,7 +151,7 @@ nonisolated enum ReadingStatsService { } } - private static func buildReadingTimeSeries(completedBooks: [Book]) -> [ReadingStatsTimePoint] { + private static func buildDailyPagesSeries(completedBooks: [Book]) -> [ReadingStatsTimePoint] { guard !completedBooks.isEmpty else { return [] } let today = Date() @@ -164,12 +162,12 @@ nonisolated enum ReadingStatsService { .map { Calendar.current.startOfDay(for: $0) } ?? Calendar.current.startOfDay(for: today) - var hoursByDay: [String: Double] = [:] + var pagesByDay: [String: Double] = [:] for book in completedBooks { guard let progress = book.readProgress else { continue } let dayKey = Self.dayKey(progress.readDate) - let hours = Double(progress.page) / 2 / 60 - hoursByDay[dayKey, default: 0] += hours + let pages = Double(readPageCount(for: book)) + pagesByDay[dayKey, default: 0] += pages } var points: [ReadingStatsTimePoint] = [] @@ -181,7 +179,7 @@ nonisolated enum ReadingStatsService { points.append( ReadingStatsTimePoint( name: dayKey, - value: hoursByDay[dayKey, default: 0], + value: pagesByDay[dayKey, default: 0], dateString: dayKey ) ) @@ -192,6 +190,17 @@ nonisolated enum ReadingStatsService { return points } + private static func readPageCount(for book: Book) -> Int { + guard let progress = book.readProgress else { return 0 } + + let mediaPagesCount = max(book.media.pagesCount, 0) + if progress.completed, mediaPagesCount > 0 { + return mediaPagesCount + } + + return max(progress.page, 0) + } + private static func buildDimensions(completedBooks: [Book], readSeries: [Series]) -> ReadingStatsDimensions { let seriesById = Dictionary(uniqueKeysWithValues: readSeries.map { ($0.id, $0) }) let booksBySeries = Dictionary(grouping: completedBooks, by: \.seriesId) diff --git a/KMReader/Features/History/ViewModels/ReadingStatsViewModel.swift b/KMReader/Features/History/ViewModels/ReadingStatsViewModel.swift index 0d8f7afd..ece45e0b 100644 --- a/KMReader/Features/History/ViewModels/ReadingStatsViewModel.swift +++ b/KMReader/Features/History/ViewModels/ReadingStatsViewModel.swift @@ -10,7 +10,7 @@ import Foundation final class ReadingStatsViewModel { var payload: ReadingStatsPayload? var lastUpdatedAt: Date? - var selectedTimeRange: ReadingStatsTimeRange = .last30Days + var selectedTimeRange: ReadingStatsTimeRange = .last90Days var isLoading = false var isRefreshing = false var errorMessage: String? @@ -68,175 +68,153 @@ final class ReadingStatsViewModel { isRefreshing = false } - func filteredTimeSeries(referenceDate: Date = Date()) -> [ReadingStatsTimePoint] { - guard let readingTimeSeries = payload?.readingTimeSeries, !readingTimeSeries.isEmpty else { - return [] - } - - let dailyPoints = readingTimeSeries.compactMap { point -> (date: Date, hours: Double)? in - guard let parsedDate = Self.parseDate(point.dateString ?? point.name) else { - return nil - } - return (Self.utcCalendar.startOfDay(for: parsedDate), point.value) - } - + func readingPagesHeatmapWeeks(referenceDate: Date = Date()) -> [ReadingPagesHeatmapWeek] { + let dailyPoints = makeDailyPagePoints() guard !dailyPoints.isEmpty else { return [] } let now = Self.utcCalendar.startOfDay(for: referenceDate) let earliestDate = dailyPoints.map(\.date).min() ?? now - let window = makeTimeSeriesWindow(referenceDate: now, earliestDate: earliestDate) + let window = makeHeatmapWindow(referenceDate: now, earliestDate: earliestDate) + guard window.startDate <= window.endDate else { + return [] + } - var hoursByKey: [String: Double] = [:] + var pagesByKey: [String: Double] = [:] for point in dailyPoints { guard point.date >= window.startDate && point.date <= window.endDate else { continue } - let key = Self.groupKey(for: point.date, grouping: window.grouping) - hoursByKey[key, default: 0] += point.hours - } - - if window.grouping == .day { - let todayKey = Self.groupKey(for: window.endDate, grouping: .day) - if hoursByKey[todayKey] == nil { - hoursByKey[todayKey] = 0 - } - } - - if window.startDate > window.endDate { - let todayKey = Self.groupKey(for: window.endDate, grouping: .day) - let todayLabel = formatAxisLabel(key: todayKey, grouping: .day) - return [ReadingStatsTimePoint(name: todayLabel, value: 0, dateString: todayKey)] + let key = Self.dayKey(for: point.date) + pagesByKey[key, default: 0] += point.pages } - var series: [ReadingStatsTimePoint] = [] + var points: [ReadingStatsTimePoint] = [] var cursor = window.startDate while cursor <= window.endDate { - let key = Self.groupKey(for: cursor, grouping: window.grouping) - let value = hoursByKey[key, default: 0] + let key = Self.dayKey(for: cursor) + let value = pagesByKey[key, default: 0] - series.append( + points.append( ReadingStatsTimePoint( - name: formatAxisLabel(key: key, grouping: window.grouping), + name: Self.dayAxisLabel(for: key), value: (value * 100).rounded() / 100, dateString: key ) ) - guard let nextCursor = Self.nextDate(from: cursor, grouping: window.grouping) else { + guard let nextCursor = Self.utcCalendar.date(byAdding: .day, value: 1, to: cursor) else { break } cursor = nextCursor } - if window.grouping == .day { - let todayKey = Self.groupKey(for: window.endDate, grouping: .day) - let hasToday = series.contains { $0.dateString == todayKey } - if !hasToday { - series.append( - ReadingStatsTimePoint( - name: formatAxisLabel(key: todayKey, grouping: .day), - value: 0, - dateString: todayKey - ) - ) - } + return Self.makeHeatmapWeeks(points: points) + } + + private func makeDailyPagePoints() -> [(date: Date, pages: Double)] { + guard let readingTimeSeries = payload?.readingTimeSeries, !readingTimeSeries.isEmpty else { + return [] } - return series + return readingTimeSeries.compactMap { point -> (date: Date, pages: Double)? in + guard let parsedDate = Self.parseDate(point.dateString ?? point.name) else { + return nil + } + return (Self.utcCalendar.startOfDay(for: parsedDate), point.value) + } } - private func makeTimeSeriesWindow(referenceDate: Date, earliestDate: Date) -> ReadingTimeWindow { + private func makeHeatmapWindow(referenceDate: Date, earliestDate: Date) -> ReadingTimeWindow { let now = Self.utcCalendar.startOfDay(for: referenceDate) - let year = Self.utcCalendar.component(.year, from: now) - let month = Self.utcCalendar.component(.month, from: now) - let day = Self.utcCalendar.component(.day, from: now) let weekday = Self.utcCalendar.component(.weekday, from: now) let startDate: Date - let grouping: ReadingTimeGrouping - switch selectedTimeRange { case .thisWeek: let offset = weekday - 1 startDate = Self.utcCalendar.date(byAdding: .day, value: -offset, to: now) ?? now - grouping = .day case .last7Days: startDate = Self.utcCalendar.date(byAdding: .day, value: -6, to: now) ?? now - grouping = .day case .last30Days: startDate = Self.utcCalendar.date(byAdding: .day, value: -29, to: now) ?? now - grouping = .day case .last90Days: - startDate = Self.utcCalendar.date(from: DateComponents(year: year, month: month - 2, day: 1)) ?? now - grouping = .month + startDate = Self.utcCalendar.date(byAdding: .day, value: -89, to: now) ?? now case .last6Months: - startDate = Self.utcCalendar.date(from: DateComponents(year: year, month: month - 5, day: 1)) ?? now - grouping = .month + startDate = Self.utcCalendar.date(byAdding: .month, value: -6, to: now) ?? now case .lastYear: - startDate = Self.utcCalendar.date(from: DateComponents(year: year - 1, month: month + 1, day: 1)) ?? now - grouping = .month + startDate = Self.utcCalendar.date(byAdding: .year, value: -1, to: now) ?? now case .allTime: - startDate = Self.utcCalendar.date(from: DateComponents(year: year, month: month, day: day)) ?? earliestDate - grouping = .year - } - - if selectedTimeRange == .allTime { - return ReadingTimeWindow(startDate: earliestDate, endDate: now, grouping: grouping) + startDate = earliestDate } return ReadingTimeWindow( startDate: Self.utcCalendar.startOfDay(for: startDate), - endDate: now, - grouping: grouping + endDate: now ) } - private func formatAxisLabel(key: String, grouping: ReadingTimeGrouping) -> String { - switch grouping { - case .day: - guard let date = Self.dayKeyFormatter.date(from: key) else { return key } - return Self.dayAxisLabelFormatter.string(from: date) - case .month: - guard let date = Self.monthKeyFormatter.date(from: key) else { return key } - return Self.monthAxisLabelFormatter.string(from: date) - case .year: - return key + private static func makeHeatmapWeeks(points: [ReadingStatsTimePoint]) -> [ReadingPagesHeatmapWeek] { + let datedPoints = points.compactMap { point -> (date: Date, point: ReadingStatsTimePoint)? in + guard let parsedDate = parseDate(point.dateString ?? point.name) else { + return nil + } + return (utcCalendar.startOfDay(for: parsedDate), point) } - } + .sorted { $0.date < $1.date } - private static func groupKey(for date: Date, grouping: ReadingTimeGrouping) -> String { - switch grouping { - case .day: - return dayKeyFormatter.string(from: date) - case .month: - return monthKeyFormatter.string(from: date) - case .year: - return yearKeyFormatter.string(from: date) + guard !datedPoints.isEmpty else { + return [] } - } - private static func nextDate(from date: Date, grouping: ReadingTimeGrouping) -> Date? { - switch grouping { - case .day: - return utcCalendar.date(byAdding: .day, value: 1, to: date) - case .month: - return utcCalendar.date(byAdding: .month, value: 1, to: date) - case .year: - return utcCalendar.date(byAdding: .year, value: 1, to: date) + var weeks: [ReadingPagesHeatmapWeek] = [] + var currentWeekStart: Date? + var currentDays = [ReadingStatsTimePoint?](repeating: nil, count: 7) + + for datedPoint in datedPoints { + let weekdayIndex = max(0, min(utcCalendar.component(.weekday, from: datedPoint.date) - 1, 6)) + let weekStart = + utcCalendar.date(byAdding: .day, value: -weekdayIndex, to: datedPoint.date) + ?? datedPoint.date + + if let currentWeekStart, currentWeekStart != weekStart { + weeks.append( + ReadingPagesHeatmapWeek( + id: dayKey(for: currentWeekStart), + days: currentDays + ) + ) + currentDays = [ReadingStatsTimePoint?](repeating: nil, count: 7) + } + + currentWeekStart = weekStart + currentDays[weekdayIndex] = datedPoint.point } - } - private enum ReadingTimeGrouping { - case day - case month - case year + if let currentWeekStart { + weeks.append( + ReadingPagesHeatmapWeek( + id: dayKey(for: currentWeekStart), + days: currentDays + ) + ) + } + + return weeks } private struct ReadingTimeWindow { let startDate: Date let endDate: Date - let grouping: ReadingTimeGrouping + } + + private static func dayKey(for date: Date) -> String { + dayKeyFormatter.string(from: date) + } + + private static func dayAxisLabel(for key: String) -> String { + guard let date = dayKeyFormatter.date(from: key) else { return key } + return dayAxisLabelFormatter.string(from: date) } static func parseDate(_ rawValue: String?) -> Date? { @@ -315,33 +293,10 @@ final class ReadingStatsViewModel { return formatter }() - private static let monthKeyFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = "yyyy-MM" - return formatter - }() - - private static let yearKeyFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = "yyyy" - return formatter - }() - private static let dayAxisLabelFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = .current formatter.setLocalizedDateFormatFromTemplate("MMMd") return formatter }() - - private static let monthAxisLabelFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = .current - formatter.setLocalizedDateFormatFromTemplate("yMMM") - return formatter - }() } diff --git a/KMReader/Features/History/Views/ReadingPagesHeatmapView.swift b/KMReader/Features/History/Views/ReadingPagesHeatmapView.swift new file mode 100644 index 00000000..b02a209e --- /dev/null +++ b/KMReader/Features/History/Views/ReadingPagesHeatmapView.swift @@ -0,0 +1,142 @@ +// +// ReadingPagesHeatmapView.swift +// +// + +import SwiftUI + +struct ReadingPagesHeatmapView: View { + let weeks: [ReadingPagesHeatmapWeek] + @Binding var selectedPointId: String? + + private let tileSize: CGFloat = 13 + private let tileSpacing: CGFloat = 4 + + private var maxValue: Double { + weeks + .flatMap(\.days) + .compactMap { $0?.value } + .max() ?? 0 + } + + private var selectedPoint: ReadingStatsTimePoint? { + guard let selectedPointId else { return nil } + return + weeks + .flatMap(\.days) + .compactMap { $0 } + .first { $0.id == selectedPointId } + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .top, spacing: tileSpacing) { + ForEach(weeks) { week in + VStack(spacing: tileSpacing) { + ForEach(0.. Color { + guard value > 0, maxValue > 0 else { + return Color.secondary.opacity(0.12) + } + + let normalized = min(max(value / maxValue, 0), 1) + switch normalized { + case ..<0.25: + return Color.accentColor.opacity(0.28) + case ..<0.5: + return Color.accentColor.opacity(0.46) + case ..<0.75: + return Color.accentColor.opacity(0.68) + default: + return Color.accentColor.opacity(0.92) + } + } + + private func legendColor(level: Int) -> Color { + switch level { + case 0: + return Color.secondary.opacity(0.12) + case 1: + return Color.accentColor.opacity(0.28) + case 2: + return Color.accentColor.opacity(0.46) + case 3: + return Color.accentColor.opacity(0.68) + default: + return Color.accentColor.opacity(0.92) + } + } + + private func displayDate(for point: ReadingStatsTimePoint) -> String { + guard let date = ReadingStatsViewModel.parseDate(point.dateString) else { + return point.name + } + return date.formattedMediumDate + } + + private func formatPageCount(_ pages: Double) -> String { + Int(max(pages.rounded(), 0)).formatted() + } +} diff --git a/KMReader/Features/History/Views/ServerReadingStatsView.swift b/KMReader/Features/History/Views/ServerReadingStatsView.swift index eeb4e3d3..a72081db 100644 --- a/KMReader/Features/History/Views/ServerReadingStatsView.swift +++ b/KMReader/Features/History/Views/ServerReadingStatsView.swift @@ -12,7 +12,7 @@ struct ServerReadingStatsView: View { @State private var selectedLibraryId: String = "" @State private var viewModel = ReadingStatsViewModel() - @State private var selectedTimePointIndex: Int? + @State private var selectedTimePointId: String? @State private var libraries: [SidebarLibraryItem] = [] @State private var syncInfo: OfflineInstanceSyncInfo? @@ -75,7 +75,7 @@ struct ServerReadingStatsView: View { } } .onChange(of: viewModel.selectedTimeRange) { _, _ in - selectedTimePointIndex = nil + selectedTimePointId = nil } .onChange(of: isOffline) { oldValue, newValue in if oldValue && !newValue { @@ -175,14 +175,13 @@ struct ServerReadingStatsView: View { @ViewBuilder private func statsContent(payload: ReadingStatsPayload) -> some View { let summary = payload.summary - let filteredTimeSeries = viewModel.filteredTimeSeries() - let indexedTimeSeries = Array(filteredTimeSeries.enumerated()) + let heatmapWeeks = viewModel.readingPagesHeatmapWeeks() summarySection(summary) VStack(alignment: .leading, spacing: 12) { HStack(spacing: 12) { - Text(String(localized: "Reading Time")) + Text(String(localized: "Pages Read")) .font(.headline) Spacer() @@ -196,108 +195,10 @@ struct ServerReadingStatsView: View { .pickerStyle(.menu) } - if filteredTimeSeries.isEmpty { - sectionEmptyState(systemImage: "chart.xyaxis.line", message: String(localized: "No data")) + if heatmapWeeks.isEmpty { + sectionEmptyState(systemImage: "square.grid.3x3", message: String(localized: "No data")) } else { - Chart(indexedTimeSeries, id: \.offset) { item in - let index = item.offset - let point = item.element - - LineMark( - x: .value("Index", index), - y: .value("Hours", point.value) - ) - .foregroundStyle(Color.accentColor) - - AreaMark( - x: .value("Index", index), - y: .value("Hours", point.value) - ) - .interpolationMethod(.catmullRom) - .foregroundStyle( - LinearGradient( - colors: [Color.accentColor.opacity(0.35), Color.accentColor.opacity(0.05)], - startPoint: .top, - endPoint: .bottom - ) - ) - - if selectedTimePointIndex == index { - PointMark( - x: .value("Index", index), - y: .value("Hours", point.value) - ) - .symbolSize(60) - .foregroundStyle(Color.accentColor) - } - } - .chartXAxis { - AxisMarks(values: axisMarkIndices(count: filteredTimeSeries.count)) { value in - if let index = value.as(Int.self), index < filteredTimeSeries.count { - AxisValueLabel(axisLabel(for: filteredTimeSeries[index])) - } - } - } - #if os(iOS) || os(macOS) - .chartOverlay { proxy in - GeometryReader { geometry in - Color.clear - .contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { drag in - guard let plotFrame = proxy.plotFrame else { return } - let plotOrigin = geometry[plotFrame].origin - let plotX = drag.location.x - plotOrigin.x - - guard plotX >= 0, plotX <= proxy.plotSize.width else { return } - - if let index = proxy.value(atX: plotX, as: Int.self) { - selectedTimePointIndex = max(0, min(index, filteredTimeSeries.count - 1)) - } else if let index = proxy.value(atX: plotX, as: Double.self) { - let rounded = Int(index.rounded()) - selectedTimePointIndex = max(0, min(rounded, filteredTimeSeries.count - 1)) - } - } - ) - } - } - #endif - .chartBackground { _ in - if let selectedTimePointIndex, - selectedTimePointIndex >= 0, - selectedTimePointIndex < filteredTimeSeries.count - { - let point = filteredTimeSeries[selectedTimePointIndex] - VStack(alignment: .leading, spacing: 4) { - Text(displayDate(for: point)) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - Text(String(localized: "Reading Time: \(formatHoursAndMinutes(point.value))")) - .font(.caption) - .fontWeight(.semibold) - .lineLimit(1) - } - .padding(8) - .background(.ultraThinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) - .padding(.top, 8) - .padding(.trailing, 8) - } - } - .frame(height: 220) - - Text(String(localized: "X-axis: Date, Y-axis: Estimated reading hours.")) - .font(.caption) - .foregroundStyle(.secondary) - - #if os(iOS) || os(macOS) - Text(String(localized: "Tip: Tap or drag a point to view details.")) - .font(.caption) - .foregroundStyle(.secondary) - #endif + ReadingPagesHeatmapView(weeks: heatmapWeeks, selectedPointId: $selectedTimePointId) } } .padding(12) @@ -355,7 +256,6 @@ struct ServerReadingStatsView: View { (String(localized: "Books Completed"), formatCount(summary.booksCompletedReading), "checkmark.circle"), (String(localized: "Pages Read"), formatCount(summary.totalPagesRead), "doc.text"), (String(localized: "Reading Days"), formatCount(summary.readingDays), "calendar"), - (String(localized: "Estimated Hours"), formatDecimal(summary.estimatedReadingHours), "hourglass"), (String(localized: "Avg Pages / Book"), formatDecimal(summary.averagePagesPerBook), "chart.bar.xaxis"), (String(localized: "Total Books"), formatCount(summary.totalBooks), "books.vertical"), (String(localized: "Last Read"), formatLastRead(summary.lastReadAt), "clock.arrow.circlepath"), @@ -519,17 +419,6 @@ struct ServerReadingStatsView: View { return date.formattedMediumDate } - private func axisMarkIndices(count: Int) -> [Int] { - guard count > 0 else { return [] } - let desiredMarks = 6 - let step = max(1, Int(ceil(Double(count) / Double(desiredMarks)))) - var indices = Array(stride(from: 0, to: count, by: step)) - if indices.last != count - 1 { - indices.append(count - 1) - } - return indices - } - private func distributionAxisIndices(count: Int, keepOrder: Bool) -> [Int] { guard count > 0 else { return [] } if keepOrder == false { @@ -544,56 +433,6 @@ struct ServerReadingStatsView: View { return indices } - private func axisLabel(for point: ReadingStatsTimePoint) -> String { - point.name - } - - private func displayDate(for point: ReadingStatsTimePoint) -> String { - if let dateString = point.dateString { - if dateString.count == 4 { - return dateString - } - - if dateString.count == 7, let date = Self.monthKeyFormatter.date(from: dateString) { - return Self.monthDisplayFormatter.string(from: date) - } - } - - guard let date = ReadingStatsViewModel.parseDate(point.dateString) else { - return point.name - } - return date.formattedMediumDate - } - - private func formatHoursAndMinutes(_ hours: Double) -> String { - let totalMinutes = Int((hours * 60).rounded()) - let hourPart = totalMinutes / 60 - let minutePart = totalMinutes % 60 - - if hourPart == 0 { - return "\(minutePart)m" - } - if minutePart == 0 { - return "\(hourPart)h" - } - return "\(hourPart)h \(minutePart)m" - } - - private static let monthKeyFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = "yyyy-MM" - return formatter - }() - - private static let monthDisplayFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = .current - formatter.setLocalizedDateFormatFromTemplate("yMMM") - return formatter - }() - private func reload(forceRefresh: Bool) async { await viewModel.load( instanceId: current.instanceId, diff --git a/KMReader/Localizable.xcstrings b/KMReader/Localizable.xcstrings index ded3d4b4..0a84189d 100644 --- a/KMReader/Localizable.xcstrings +++ b/KMReader/Localizable.xcstrings @@ -28026,70 +28026,6 @@ } } }, - "Estimated Hours" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Geschätzte Stunden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Estimated Hours" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Horas estimadas" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Heures estimées" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ore stimate" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "推定時間" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "예상 시간" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Оценочные часы" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "预计小时数" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "預計小時數" - } - } - } - }, "Every 6 hours" : { "localizations" : { "de" : { @@ -30970,70 +30906,6 @@ } } }, - "Hours" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Stunden" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hours" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Horas" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Heures" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ore" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "時間" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "시간" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Часы" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "小时" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "小時" - } - } - } - }, "Idle" : { "localizations" : { "de" : { @@ -59068,134 +58940,6 @@ } } }, - "Reading Time" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lesezeit" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reading Time" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tiempo de lectura" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temps de lecture" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tempo di lettura" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "読書時間" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "읽기 시간" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Время чтения" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "阅读时长" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "閱讀時長" - } - } - } - }, - "Reading Time: %@" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lesezeit: %@" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reading Time: %@" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tiempo de lectura: %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temps de lecture : %@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tempo di lettura: %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "読書時間: %@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "읽기 시간: %@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Время чтения: %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "阅读时长:%@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "閱讀時長:%@" - } - } - } - }, "reading_direction.ltr" : { "localizations" : { "de" : { @@ -78780,70 +78524,6 @@ } } }, - "Tip: Tap or drag a point to view details." : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tipp: Tippen oder ziehen Sie auf einen Punkt, um Details anzuzeigen." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tip: Tap or drag a point to view details." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Consejo: toca o arrastra un punto para ver detalles." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Astuce : touchez ou faites glisser un point pour voir les détails." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Suggerimento: tocca o trascina un punto per vedere i dettagli." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ヒント: ポイントをタップまたはドラッグして詳細を表示します。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "팁: 포인트를 탭하거나 드래그해 상세 정보를 확인하세요." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Совет: коснитесь или перетащите точку, чтобы посмотреть детали." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "提示:点击或拖动数据点可查看详情。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "提示:點擊或拖動資料點可查看詳情。" - } - } - } - }, "Title" : { "localizations" : { "de" : { @@ -83324,70 +83004,6 @@ } } }, - "X-axis: Date, Y-axis: Estimated reading hours." : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "X-Achse: Datum, Y-Achse: Geschätzte Lesestunden." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "X-axis: Date, Y-axis: Estimated reading hours." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Eje X: fecha, eje Y: horas estimadas de lectura." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Axe X : date, axe Y : heures de lecture estimées." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Asse X: data, asse Y: ore di lettura stimate." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "X軸: 日付、Y軸: 推定読書時間。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "X축: 날짜, Y축: 예상 독서 시간." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ось X: дата, ось Y: оценочные часы чтения." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "X轴:日期,Y轴:预计阅读小时。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "X軸:日期,Y軸:預計閱讀小時。" - } - } - } - }, "You can adjust the font size, spacing, and other settings to find what works best for you. Each paragraph demonstrates how the text will appear with your current choices." : { "localizations" : { "de" : { From eb077c6da90ce8be364bbaeb47827fb00cdedf6f Mon Sep 17 00:00:00 2001 From: everpcpc Date: Tue, 7 Jul 2026 17:55:24 +0800 Subject: [PATCH 2/2] chore: incr build ver to 502 --- KMReader.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/KMReader.xcodeproj/project.pbxproj b/KMReader.xcodeproj/project.pbxproj index 29cc3717..4d7b0718 100644 --- a/KMReader.xcodeproj/project.pbxproj +++ b/KMReader.xcodeproj/project.pbxproj @@ -442,7 +442,7 @@ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "KMReader/KMReader-macOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 501; + CURRENT_PROJECT_VERSION = 502; DEVELOPMENT_TEAM = M777UHWZA4; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -500,7 +500,7 @@ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "KMReader/KMReader-macOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 501; + CURRENT_PROJECT_VERSION = 502; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=appletvos*]" = M777UHWZA4; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4; @@ -560,7 +560,7 @@ CODE_SIGN_ENTITLEMENTS = KMReaderWidgets/KMReaderWidgets.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 501; + CURRENT_PROJECT_VERSION = 502; DEVELOPMENT_TEAM = M777UHWZA4; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = KMReaderWidgets/Info.plist; @@ -594,7 +594,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 501; + CURRENT_PROJECT_VERSION = 502; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4; GENERATE_INFOPLIST_FILE = YES;