Files
Felitendo 8456aacd49 fix: rewrite the operator copy that still used an em-dash
An em-dash reads as machine-written, and no string a person can end up looking
at is meant to carry one. Three had come back: the caveat under the install
counts and the note under the board's posting switch, both on the console's own
screens, and the confirmation the dashboard shows before emptying a board.

All three wanted a full stop or a colon, which is what they have now. The
sentence under the install counts is the source string of a catalogue entry, so
the entry moved with the call site rather than being left behind as a key
nothing asks for any more, and the copy that is kept alongside it was rewritten
the same way in every locale it ships in.

One more is left and is deliberately not touched here: the credit under the
About screen's acknowledgements has a catalogue entry that no longer matches
what the screen asks for, so the whole entry needs rewriting for a reason that
has nothing to do with punctuation. That is its own change.
2026-08-31 10:29:15 +02:00

205 lines
6.0 KiB
Swift

//
// AdminUsageView.swift
// FTStore Admin
//
// Who is using this, and what they are taking.
//
// Two questions the dashboard next door cannot answer. Its counters are about
// the catalog — what the server ingested, how the pipeline is doing — and none
// of that says whether anybody fetched any of it.
//
// **What "installs" means here.** The board mints an anonymous row per install
// on first contact and stamps a recency time at most once an hour. So these
// count installs that have *opened the board*, not everyone running the app.
// The screen says that out loud rather than putting it in a tooltip: a number
// labelled "users" that quietly meant something narrower is worse than no
// number, and an operator making decisions on it deserves to know which.
//
// Nothing here is per person. No path, no address, no history — a stamp on a
// row that already existed, aggregated. Which install downloaded what is a log
// this server deliberately does not keep, so this screen cannot show it.
//
import SwiftUI
struct AdminUsageView: View {
@EnvironmentObject private var session: AdminSession
@StateObject private var _usage = AdminLoader<AdminUsage>()
private var _data: AdminUsage.Payload? { _usage.value?.data }
var body: some View {
List {
if _usage.isLoading && !_usage.hasLoaded {
AdminLoadingRow()
} else if let error = _usage.errorMessage, !_usage.hasLoaded {
AdminErrorRow(message: error) { Task { await _reload() } }
} else if let data = _data {
_installsSection(data.installs)
_boardsSection(data.boards)
_downloadsSection(data.downloads)
}
}
.navigationTitle("Usage")
.navigationBarTitleDisplayMode(.inline)
.refreshable { await _reload() }
.task { await _reload() }
}
// MARK: People
@ViewBuilder
private func _installsSection(_ installs: AdminUsage.Installs) -> some View {
Section {
LazyVGrid(
columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)],
spacing: 10
) {
AdminKPITile(
title: "Today",
value: AdminFormat.count(installs.seenDay),
detail: "installs seen",
icon: "iphone.gen3"
)
AdminKPITile(
title: "This week",
value: AdminFormat.count(installs.seenWeek),
detail: "installs seen",
icon: "calendar"
)
AdminKPITile(
title: "This month",
value: AdminFormat.count(installs.seenMonth),
detail: "installs seen",
icon: "calendar.badge.clock"
)
AdminKPITile(
title: "Known",
value: AdminFormat.count(installs.known),
detail: "since the board opened",
icon: "person.2.fill"
)
}
.listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12))
.listRowBackground(Color.clear)
HStack {
Text("Registered names")
Spacer()
Text(verbatim: AdminFormat.count(installs.named))
.foregroundStyle(.secondary)
.monospacedDigit()
}
HStack {
Text("Muted")
Spacer()
Text(verbatim: AdminFormat.count(installs.muted))
.foregroundStyle(installs.muted > 0 ? .red : .secondary)
.monospacedDigit()
}
} header: {
Text("People")
} footer: {
// The caveat belongs on the screen, not behind it.
if installs.measured {
Text("Counts installs that have opened Feedback or Tweak Requests, not everyone running FTStore. Somebody who only browses the store is not in these. Recency is stamped at most once an hour, and nothing about which install did what is recorded.")
} else {
Text("Nothing measured yet. Recency is only stamped when an install opens Feedback or Tweak Requests, so these fill in as people do.")
}
}
}
// MARK: Boards
@ViewBuilder
private func _boardsSection(_ boards: AdminUsage.Boards) -> some View {
Section {
HStack {
Label("Open feedback", systemImage: "bubble.left.and.bubble.right")
Spacer()
Text(verbatim: AdminFormat.count(boards.openFeedback))
.foregroundStyle(.secondary)
.monospacedDigit()
}
HStack {
Label("Pending requests", systemImage: "tray.and.arrow.down")
Spacer()
Text(verbatim: AdminFormat.count(boards.pendingRequests))
.foregroundStyle(.secondary)
.monospacedDigit()
}
} header: {
Text("Waiting on you")
} footer: {
Text(verbatim: String.localized(
"%lld reports and %lld requests arrived in the last 7 days.",
boards.feedbackThisWeek,
boards.requestsThisWeek
))
}
}
// MARK: Downloads
@ViewBuilder
private func _downloadsSection(_ downloads: AdminUsage.Downloads) -> some View {
Section {
if downloads.apps.isEmpty {
AdminEmptyRow(
icon: "arrow.down.circle",
title: "Nothing downloaded yet",
detail: "This counts what people fetched, not what the scraper ingested."
)
} else {
ForEach(downloads.apps) { app in
HStack(alignment: .firstTextBaseline, spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(app.name)
.font(.subheadline)
.lineLimit(2)
Text(app.bundleId)
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 8)
Text(verbatim: AdminFormat.count(app.downloads))
.font(.subheadline.weight(.semibold).monospacedDigit())
.foregroundStyle(AdminTheme.brand)
}
.padding(.vertical, 2)
}
}
} header: {
Text("Most downloaded")
} footer: {
Text(verbatim: _downloadsFooter(downloads))
}
}
/// Built outside the builder: a `var` and a `return` inside a `@ViewBuilder`
/// closure is the kind of thing that compiles until it doesn't.
private func _downloadsFooter(_ downloads: AdminUsage.Downloads) -> String {
var line = String.localized(
"%@ downloads all time. Versions of one app are counted together.",
AdminFormat.count(downloads.total)
)
if downloads.truncated {
line += " " + .localized("The ranking covers the most-fetched builds only.")
}
return line
}
// MARK: Actions
private func _reload() async {
await _usage.load {
try await session.get("/api/dashboard/usage", as: AdminUsage.self)
}
}
}