Pendant zur Web-Route /rueckblick. Synthetisiert, was man WIRKLICH gelesen hat (status=finished oder readingProgress>=0.5 im Fenster). - Core/Domain/Digest.swift: reine, getestete Logik — Swift-Spiegel von pageta-domain/digest.ts (Auswahl nach Zeitfenster + Lese-Signal, Theme-Gruppierung nach Tags, Prompt-Builder). 8 XCTest-Fälle, grün. - Features/RueckblickTabView.swift: deterministisches Gerüst steht IMMER (gratis, offline) — Kennzahlen, Themencluster, eigene Markierungen wörtlich. Darüber optionale Synthese via ManaLLM.generate (lokal-first; liefert sie nichts, bleibt das Gerüst nutzbar). Tag/Woche/Gesamt-Picker, Markdown-ShareLink, Tap → ArticleDetailView. - RootView: neuer Tab „Rückblick" (iOS TabView + macOS Sidebar), Icon clock.arrow.circlepath, nach Markierungen. - Motivations-Leitplanke (Wert 8): ruhiger Spiegel, keine Streaks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
404 lines
11 KiB
Swift
404 lines
11 KiB
Swift
import ManaAuthUI
|
|
import ManaCore
|
|
import ManaLLM
|
|
import SwiftUI
|
|
|
|
/// Lese-Rückblick: synthetisiert, was die Person WIRKLICH gelesen hat
|
|
/// (fertig oder substanziell gescrollt) — Tag/Woche/Gesamt. Pendant zur
|
|
/// Web-Route `/rueckblick`.
|
|
///
|
|
/// Architektur wie im Web: ein **deterministisches Gerüst** (Kennzahlen,
|
|
/// Themen, eigene Markierungen) steht IMMER — gratis, offline, ohne LLM.
|
|
/// Darüber eine **optionale Synthese** via `ManaLLM.generate` (lokal-first;
|
|
/// liefert sie nichts, bleibt das Gerüst nutzbar). Reine Auswahl-/Gruppier-/
|
|
/// Prompt-Logik in `Digest` (testbar, geteilt mit der Web-Konvention).
|
|
///
|
|
/// Motivations-Leitplanke (Wert 8): ruhiger Spiegel, keine Streaks/Belohnung.
|
|
struct RueckblickTabView: View {
|
|
@Environment(AuthClient.self) private var auth
|
|
@Environment(PendingShareSignal.self) private var shareSignal
|
|
private var api: PagetaAPI { PagetaAPI(auth: auth) }
|
|
|
|
@State private var articles: [Article] = []
|
|
@State private var allHighlights: [HighlightWithArticle] = []
|
|
@State private var loadState: LoadState = .idle
|
|
@State private var scope: DigestScope = .week
|
|
// Fenster-Bezugspunkt einmal pro Load festhalten.
|
|
@State private var loadedAt = Date(timeIntervalSince1970: 0)
|
|
|
|
@State private var synthesizing = false
|
|
@State private var synthesis: String?
|
|
@State private var synthesisError: String?
|
|
@State private var synthesisInfo: String?
|
|
|
|
enum LoadState: Equatable {
|
|
case idle
|
|
case loading
|
|
case loaded
|
|
case error(String)
|
|
}
|
|
|
|
// MARK: - Abgeleitetes Gerüst (rein, gratis)
|
|
|
|
private var selected: [Article] {
|
|
Digest.selectArticles(articles, scope: scope, now: loadedAt)
|
|
}
|
|
|
|
private func briefText(_ a: Article) -> String {
|
|
if let ex = a.excerpt?.trimmingCharacters(in: .whitespacesAndNewlines), !ex.isEmpty {
|
|
return ex
|
|
}
|
|
return String(a.content.prefix(280))
|
|
}
|
|
|
|
private var briefs: [DigestBrief] {
|
|
selected.map { a in
|
|
DigestBrief(
|
|
id: a.id,
|
|
title: a.displayTitle,
|
|
source: a.siteName,
|
|
url: a.id,
|
|
brief: briefText(a),
|
|
tags: a.tags
|
|
)
|
|
}
|
|
}
|
|
|
|
private var themeGroups: [ThemeGroup] { Digest.groupByTheme(briefs) }
|
|
|
|
private var highlightLines: [DigestHighlightLine] {
|
|
let ids = Set(selected.map(\.id))
|
|
return allHighlights
|
|
.filter { ids.contains($0.article.id) }
|
|
.map {
|
|
DigestHighlightLine(
|
|
articleId: $0.article.id,
|
|
source: $0.article.siteName,
|
|
title: $0.article.displayTitle,
|
|
text: $0.highlight.text,
|
|
note: $0.highlight.note
|
|
)
|
|
}
|
|
}
|
|
|
|
private var highlightsByArticle: [(title: String, source: String?, lines: [DigestHighlightLine])] {
|
|
var order: [String] = []
|
|
var map: [String: (title: String, source: String?, lines: [DigestHighlightLine])] = [:]
|
|
for h in highlightLines {
|
|
if map[h.articleId] == nil {
|
|
order.append(h.articleId)
|
|
map[h.articleId] = (h.title, h.source, [])
|
|
}
|
|
map[h.articleId]?.lines.append(h)
|
|
}
|
|
return order.compactMap { map[$0] }
|
|
}
|
|
|
|
private var totalMinutes: Int {
|
|
selected.reduce(0) { $0 + ($1.readingTimeMinutes ?? 0) }
|
|
}
|
|
|
|
private var sourceCount: Int {
|
|
Set(selected.compactMap { $0.siteName?.lowercased() }.filter { !$0.isEmpty }).count
|
|
}
|
|
|
|
// MARK: - Body
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
switch loadState {
|
|
case .idle, .loading:
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
case let .error(message):
|
|
ContentUnavailableView(
|
|
"Rückblick nicht verfügbar",
|
|
systemImage: "exclamationmark.triangle",
|
|
description: Text(message)
|
|
)
|
|
case .loaded:
|
|
contentScroll
|
|
}
|
|
}
|
|
.navigationTitle("Rückblick")
|
|
.navigationDestination(for: String.self) { articleId in
|
|
ArticleDetailView(articleId: articleId)
|
|
}
|
|
.paperBackground()
|
|
.task {
|
|
if loadState == .idle { await load() }
|
|
}
|
|
.onAppear {
|
|
if loadState != .idle, loadState != .loading {
|
|
Task { await load() }
|
|
}
|
|
}
|
|
.refreshable { await load() }
|
|
.onChange(of: shareSignal.flushedTick) { _, _ in
|
|
Task { await load() }
|
|
}
|
|
.onChange(of: scope) { _, _ in resetSynthesis() }
|
|
}
|
|
}
|
|
|
|
private var contentScroll: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
Text("Was du gelesen hast — als ruhiger Spiegel, nicht als Punktestand.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Picker("Zeitraum", selection: $scope) {
|
|
ForEach(DigestScope.allCases) { s in
|
|
Text(s.label).tag(s)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
|
|
if selected.isEmpty {
|
|
ContentUnavailableView(
|
|
"Noch nichts gelesen",
|
|
systemImage: "book.closed",
|
|
description: Text(
|
|
"In diesem Zeitraum hast du noch nichts (zu Ende) gelesen. Sobald du Artikel liest, erscheinen sie hier."
|
|
)
|
|
)
|
|
.padding(.top, 24)
|
|
} else {
|
|
statsRow
|
|
synthesisCard
|
|
themesSection
|
|
if !highlightsByArticle.isEmpty { highlightsSection }
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 16)
|
|
.macReadingWidth()
|
|
}
|
|
.toolbar {
|
|
if !selected.isEmpty {
|
|
ShareLink(item: buildMarkdown()) {
|
|
Image(systemName: "square.and.arrow.up")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var statsRow: some View {
|
|
HStack(spacing: 18) {
|
|
stat("\(selected.count)", "Artikel")
|
|
stat("\(totalMinutes)", "Min")
|
|
stat("\(sourceCount)", "Quellen")
|
|
if !highlightLines.isEmpty {
|
|
stat("\(highlightLines.count)", "Marker")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func stat(_ value: String, _ label: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(value).font(.title3.weight(.semibold)).foregroundStyle(PagetaTheme.foreground)
|
|
Text(label).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private var synthesisCard: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Text("Synthese")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Button {
|
|
Task { await synthesize() }
|
|
} label: {
|
|
Label(
|
|
synthesizing ? "Erzeuge …" : (synthesis == nil ? "Synthese erzeugen" : "Neu erzeugen"),
|
|
systemImage: "sparkles"
|
|
)
|
|
.font(.callout.weight(.medium))
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(PagetaTheme.primary)
|
|
.disabled(synthesizing)
|
|
}
|
|
|
|
if synthesizing {
|
|
Text("Läuft lokal auf deinem Gerät — das kann einen Moment dauern.")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
} else if let synthesisError {
|
|
Text(synthesisError)
|
|
.font(.callout)
|
|
.foregroundStyle(.orange)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
} else if let synthesis {
|
|
Text(synthesis)
|
|
.font(.callout)
|
|
.foregroundStyle(PagetaTheme.foreground)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
if let synthesisInfo {
|
|
Text(synthesisInfo).font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
} else {
|
|
Text(
|
|
"Überblick und rote Fäden über deine gelesenen Artikel — lokal erzeugt. Optional; das Gerüst unten steht auch ohne."
|
|
)
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(PagetaTheme.surfaceHover, in: RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
|
|
private var themesSection: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Themen")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
ForEach(themeGroups) { group in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 6) {
|
|
Text(group.theme).font(.headline)
|
|
Text("· \(group.articles.count)").font(.subheadline).foregroundStyle(.secondary)
|
|
}
|
|
ForEach(group.articles) { brief in
|
|
NavigationLink(value: brief.url) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
Text(brief.title)
|
|
.font(.callout)
|
|
.foregroundStyle(PagetaTheme.foreground)
|
|
.multilineTextAlignment(.leading)
|
|
if let source = brief.source {
|
|
Text("· \(source)").font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var highlightsSection: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Deine Markierungen")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
ForEach(Array(highlightsByArticle.enumerated()), id: \.offset) { _, group in
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 6) {
|
|
Text(group.title).font(.subheadline.weight(.medium))
|
|
if let source = group.source {
|
|
Text("· \(source)").font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
ForEach(Array(group.lines.enumerated()), id: \.offset) { _, line in
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(line.text)
|
|
.font(.callout)
|
|
.italic()
|
|
.foregroundStyle(PagetaTheme.foreground)
|
|
if let note = line.note, !note.isEmpty {
|
|
Text("— \(note)").font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.leading, 10)
|
|
.overlay(alignment: .leading) {
|
|
Rectangle().fill(PagetaTheme.primary).frame(width: 2)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Aktionen
|
|
|
|
private func resetSynthesis() {
|
|
synthesis = nil
|
|
synthesisError = nil
|
|
synthesisInfo = nil
|
|
}
|
|
|
|
private func load() async {
|
|
loadState = .loading
|
|
resetSynthesis()
|
|
do {
|
|
async let a = api.fetchArticles(limit: 500)
|
|
async let h = api.fetchAllHighlights()
|
|
articles = try await a
|
|
allHighlights = try await h
|
|
loadedAt = Date()
|
|
loadState = .loaded
|
|
} catch {
|
|
loadState = .error(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private func synthesize() async {
|
|
let currentBriefs = briefs
|
|
let currentHighlights = highlightLines
|
|
guard !currentBriefs.isEmpty, !synthesizing else { return }
|
|
synthesizing = true
|
|
resetSynthesis()
|
|
let p = Digest.buildSynthesisPrompt(
|
|
scope: scope,
|
|
briefs: currentBriefs,
|
|
highlights: currentHighlights
|
|
)
|
|
let result = await ManaLLM.generate(
|
|
prompt: p.prompt,
|
|
instructions: p.instructions,
|
|
level: .creative,
|
|
maxTokens: 700
|
|
)
|
|
let clean = result?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if clean.count >= 20 {
|
|
synthesis = clean
|
|
var parts: [String] = []
|
|
if p.usedArticles < currentBriefs.count {
|
|
parts.append("\(p.usedArticles) von \(currentBriefs.count) Artikeln")
|
|
}
|
|
if p.usedHighlights < currentHighlights.count {
|
|
parts.append("\(p.usedHighlights) von \(currentHighlights.count) Markierungen")
|
|
}
|
|
synthesisInfo = parts.isEmpty ? nil : "Synthese aus \(parts.joined(separator: " und ")) — Rest gekürzt."
|
|
} else {
|
|
synthesisError =
|
|
"Die lokale KI hat keine Synthese erzeugt — auf diesem Gerät ist eventuell kein Modell verfügbar. Das Gerüst unten steht unabhängig davon."
|
|
}
|
|
synthesizing = false
|
|
}
|
|
|
|
private func buildMarkdown() -> String {
|
|
var lines: [String] = ["# Rückblick — \(scope.label)", ""]
|
|
lines.append("\(selected.count) Artikel · \(totalMinutes) Min · \(sourceCount) Quellen")
|
|
lines.append("")
|
|
if let synthesis { lines.append(synthesis); lines.append("") }
|
|
lines.append("## Gelesen (\(selected.count))")
|
|
for a in selected {
|
|
let src = a.siteName.map { " — \($0)" } ?? ""
|
|
lines.append("- [\(a.displayTitle)](\(a.originalUrl))\(src)")
|
|
}
|
|
if !highlightsByArticle.isEmpty {
|
|
lines.append("")
|
|
lines.append("## Deine Markierungen")
|
|
for group in highlightsByArticle {
|
|
lines.append("")
|
|
lines.append("**\(group.title)**")
|
|
for line in group.lines {
|
|
lines.append("> \(line.text)")
|
|
if let note = line.note, !note.isEmpty { lines.append("> — \(note)") }
|
|
}
|
|
}
|
|
}
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
}
|