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.
205 lines
6.0 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|