Improve sample view

This commit is contained in:
Christoph Hagen 2024-01-31 11:02:26 +01:00
parent 218705a4d2
commit ac96e6d4a5
9 changed files with 302 additions and 87 deletions

View File

@ -39,6 +39,8 @@
E201EC772B626FC1005B83D3 /* MetadataKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC762B626FC1005B83D3 /* MetadataKey.swift */; };
E201EC792B627572005B83D3 /* MetadataKey+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC782B627572005B83D3 /* MetadataKey+SQLite.swift */; };
E201EC7B2B6275CA005B83D3 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC7A2B6275CA005B83D3 /* Metadata.swift */; };
E201EC7D2B62930E005B83D3 /* Sample+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC7C2B62930E005B83D3 /* Sample+SQLite.swift */; };
E201EC7F2B629B4C005B83D3 /* SampleListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC7E2B629B4C005B83D3 /* SampleListView.swift */; };
E27BC67A2B5D99AC003A8873 /* LocationSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6792B5D99AC003A8873 /* LocationSample.swift */; };
E27BC67E2B5E6CE3003A8873 /* Sequence+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC67D2B5E6CE3003A8873 /* Sequence+Extensions.swift */; };
E27BC6802B5E74D7003A8873 /* LocationSampleListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC67F2B5E74D7003A8873 /* LocationSampleListView.swift */; };
@ -47,6 +49,7 @@
E27BC6862B5FBF0B003A8873 /* Sample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6852B5FBF0B003A8873 /* Sample.swift */; };
E27BC6882B5FC220003A8873 /* Sample+Quantity.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6872B5FC220003A8873 /* Sample+Quantity.swift */; };
E27BC68A2B5FC255003A8873 /* Sample+Unit.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6892B5FC255003A8873 /* Sample+Unit.swift */; };
E27BC68C2B5FC842003A8873 /* ActivitySamplesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68B2B5FC842003A8873 /* ActivitySamplesView.swift */; };
E27BC68E2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68D2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift */; };
E27BC6902B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */; };
E27BC6922B5FD488003A8873 /* HealthDatabase+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6912B5FD488003A8873 /* HealthDatabase+Mock.swift */; };
@ -85,6 +88,8 @@
E201EC762B626FC1005B83D3 /* MetadataKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataKey.swift; sourceTree = "<group>"; };
E201EC782B627572005B83D3 /* MetadataKey+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MetadataKey+SQLite.swift"; sourceTree = "<group>"; };
E201EC7A2B6275CA005B83D3 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = "<group>"; };
E201EC7C2B62930E005B83D3 /* Sample+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sample+SQLite.swift"; sourceTree = "<group>"; };
E201EC7E2B629B4C005B83D3 /* SampleListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleListView.swift; sourceTree = "<group>"; };
E27BC6792B5D99AC003A8873 /* LocationSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSample.swift; sourceTree = "<group>"; };
E27BC67D2B5E6CE3003A8873 /* Sequence+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Extensions.swift"; sourceTree = "<group>"; };
E27BC67F2B5E74D7003A8873 /* LocationSampleListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSampleListView.swift; sourceTree = "<group>"; };
@ -93,6 +98,7 @@
E27BC6852B5FBF0B003A8873 /* Sample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sample.swift; sourceTree = "<group>"; };
E27BC6872B5FC220003A8873 /* Sample+Quantity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sample+Quantity.swift"; sourceTree = "<group>"; };
E27BC6892B5FC255003A8873 /* Sample+Unit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sample+Unit.swift"; sourceTree = "<group>"; };
E27BC68B2B5FC842003A8873 /* ActivitySamplesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivitySamplesView.swift; sourceTree = "<group>"; };
E27BC68D2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutEvent+SQLite.swift"; sourceTree = "<group>"; };
E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutActivity+SQLite.swift"; sourceTree = "<group>"; };
E27BC6912B5FD488003A8873 /* HealthDatabase+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HealthDatabase+Mock.swift"; sourceTree = "<group>"; };
@ -140,6 +146,8 @@
8850025C2B5C273C00E7D4DB /* ContentView.swift */,
8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */,
885002922B5D129300E7D4DB /* ActivityDetailView.swift */,
E27BC68B2B5FC842003A8873 /* ActivitySamplesView.swift */,
E201EC7E2B629B4C005B83D3 /* SampleListView.swift */,
E27BC67F2B5E74D7003A8873 /* LocationSampleListView.swift */,
E27BC6812B5E762D003A8873 /* LocationSampleDetailView.swift */,
8850029C2B5D197300E7D4DB /* EventDetailView.swift */,
@ -194,6 +202,7 @@
8850029E2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift */,
E27BC6852B5FBF0B003A8873 /* Sample.swift */,
E27BC6872B5FC220003A8873 /* Sample+Quantity.swift */,
E201EC7C2B62930E005B83D3 /* Sample+SQLite.swift */,
E27BC6892B5FC255003A8873 /* Sample+Unit.swift */,
8850027E2B5C36A700E7D4DB /* Workout.swift */,
8850027A2B5C35BF00E7D4DB /* Workout+SQLite.swift */,
@ -302,6 +311,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
E201EC7F2B629B4C005B83D3 /* SampleListView.swift in Sources */,
E27BC6982B5FD76F003A8873 /* Data+Extensions.swift in Sources */,
885002872B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift in Sources */,
8850025D2B5C273C00E7D4DB /* ContentView.swift in Sources */,
@ -324,6 +334,7 @@
885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */,
8850029F2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift in Sources */,
E27BC6882B5FC220003A8873 /* Sample+Quantity.swift in Sources */,
E201EC7D2B62930E005B83D3 /* Sample+SQLite.swift in Sources */,
8850027B2B5C35BF00E7D4DB /* Workout+SQLite.swift in Sources */,
E27BC6962B5FD61D003A8873 /* WorkoutEvent+Mock.swift in Sources */,
E27BC6802B5E74D7003A8873 /* LocationSampleListView.swift in Sources */,
@ -338,6 +349,7 @@
8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */,
E27BC6862B5FBF0B003A8873 /* Sample.swift in Sources */,
E27BC67E2B5E6CE3003A8873 /* Sequence+Extensions.swift in Sources */,
E27BC68C2B5FC842003A8873 /* ActivitySamplesView.swift in Sources */,
E27BC6942B5FD587003A8873 /* Workout+Mock.swift in Sources */,
E27BC68E2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift in Sources */,
8850025B2B5C273C00E7D4DB /* HealthImportApp.swift in Sources */,

View File

@ -9,6 +9,8 @@ struct ActivityDetailView: View {
@State var locations: [LocationSample] = []
@State var sampleCount: Int = 0
var body: some View {
List {
DetailRow("UUID", value: activity.uuid)
@ -28,6 +30,16 @@ struct ActivityDetailView: View {
} else {
DetailRow("Locations", value: "0")
}
if sampleCount != 0 {
NavigationLink {
ActivitySamplesView(activity: activity)
.environmentObject(database)
} label: {
DetailRow("Samples", value: "\(sampleCount)")
}
} else {
DetailRow("Samples", value: "0")
}
}
.navigationTitle("Activity")
.navigationDestination(for: [LocationSample].self) { locations in
@ -41,8 +53,10 @@ struct ActivityDetailView: View {
do {
let samples = try database.locationSamples(for: activity)
.sorted { $0.timestamp }
let sampleCount = try database.sampleCount(for: activity)
DispatchQueue.main.async {
self.locations = samples
self.sampleCount = sampleCount
}
} catch {
print("Failed to load location samples for activity: \(error)")

View File

@ -0,0 +1,52 @@
import SwiftUI
import OrderedCollections
struct ActivitySamplesView: View {
@EnvironmentObject
var database: HealthDatabase
let activity: WorkoutActivity
@State var samples: [(type: Sample.DataType, samples: [Sample])] = []
init(activity: WorkoutActivity) {
self.activity = activity
}
var body: some View {
List {
ForEach(samples, id: \.0) { entry in
NavigationLink {
SampleListView(type: entry.type, samples: entry.samples)
} label: {
DetailRow(entry.type.description, value: entry.samples.count)
}
}
}.onAppear(perform: load)
}
private func load() {
Task {
self.loadAsync()
}
}
private func loadAsync() {
do {
let samples = try database.samples(for: activity)
let ordered = samples
.sorted(using: { $0.key.rawValue })
.map { (type: $0, samples: $1) }
DispatchQueue.main.async {
self.samples = ordered
}
} catch {
print("Failed to load samples: \(error)")
}
}
}
#Preview {
ActivitySamplesView(activity: .mock1)
}

View File

@ -43,6 +43,14 @@ final class HealthDatabase: ObservableObject {
try activity.locationSamples(in: database)
}
func samples(for activity: WorkoutActivity) throws -> [Sample.DataType : [Sample]] {
try activity.samples(in: database)
}
func sampleCount(for activity: WorkoutActivity) throws -> Int {
try activity.sampleCount(in: database)
}
var activities: [WorkoutActivity] {
workouts.map { $0.activities }.joined().sorted()
}

View File

@ -9,63 +9,84 @@ extension LocationSample {
private static let table = Table("location_series_data")
/// `location_series_data[series_identifier]` <-> `workout_activities[ROW_ID]`
private static let rowSeriesIdentifier = Expression<Int>("series_identifier")
private static let columnSeriesIdentifier = Expression<Int>("series_identifier")
private static let rowTimestamp = Expression<Double>("timestamp")
private static let columnTimestamp = Expression<Double>("timestamp")
private static let rowLongitude = Expression<Double>("longitude")
private static let columnLongitude = Expression<Double>("longitude")
private static let rowLatitude = Expression<Double>("latitude")
private static let columnLatitude = Expression<Double>("latitude")
private static let rowAltitude = Expression<Double>("altitude")
private static let columnAltitude = Expression<Double>("altitude")
private static let rowSpeed = Expression<Double>("speed")
private static let columnSpeed = Expression<Double>("speed")
private static let rowCourse = Expression<Double>("course")
private static let columnCourse = Expression<Double>("course")
private static let rowHorizontalAccuracy = Expression<Double>("horizontal_accuracy")
private static let columnHorizontalAccuracy = Expression<Double>("horizontal_accuracy")
private static let rowVerticalAccuracy = Expression<Double>("vertical_accuracy")
private static let columnVerticalAccuracy = Expression<Double>("vertical_accuracy")
private static let rowSpeedAccuracy = Expression<Double>("speed_accuracy")
private static let columnSpeedAccuracy = Expression<Double>("speed_accuracy")
private static let rowCourseAccuracy = Expression<Double>("course_accuracy")
private static let columnCourseAccuracy = Expression<Double>("course_accuracy")
private static let rowSignalEnvironment = Expression<Double>("signal_environment")
private static let columnSignalEnvironment = Expression<Double>("signal_environment")
static func locationSamples(for seriesId: Int, in database: Database) throws -> [LocationSample] {
try database.prepare(table.filter(rowSeriesIdentifier == seriesId)).map(location)
try database.prepare(table.filter(columnSeriesIdentifier == seriesId)).map(location)
}
static func locationSampleCount(for seriesId: Int, in database: Database) throws -> Int {
try database.scalar(table.filter(rowSeriesIdentifier == seriesId).count)
try database.scalar(table.filter(columnSeriesIdentifier == seriesId).count)
}
static func locationSamples(from start: Date, to end: Date, in database: Database) throws -> [LocationSample] {
let startTime = start.timeIntervalSinceReferenceDate
let endTime = end.timeIntervalSinceReferenceDate
return try database.prepare(table.filter(rowTimestamp >= startTime && rowTimestamp <= endTime)).map(location)
return try database.prepare(table.filter(columnTimestamp >= startTime && columnTimestamp <= endTime)).map(location)
}
static func locationSampleCount(from start: Date, to end: Date, in database: Database) throws -> Int {
let startTime = start.timeIntervalSinceReferenceDate
let endTime = end.timeIntervalSinceReferenceDate
return try database.scalar(table.filter(rowTimestamp >= startTime && rowTimestamp <= endTime).count)
return try database.scalar(table.filter(columnTimestamp >= startTime && columnTimestamp <= endTime).count)
}
private static func location(row: Row) -> LocationSample {
.init(
coordinate: .init(
latitude: row[rowLatitude],
longitude: row[rowLongitude]),
altitude: row[rowAltitude],
horizontalAccuracy: row[rowHorizontalAccuracy],
verticalAccuracy: row[rowHorizontalAccuracy],
course: row[rowCourse],
courseAccuracy: row[rowCourseAccuracy],
speed: row[rowSpeed],
speedAccuracy: row[rowSpeedAccuracy],
timestamp: .init(timeIntervalSinceReferenceDate: row[rowTimestamp]),
latitude: row[columnLatitude],
longitude: row[columnLongitude]),
altitude: row[columnAltitude],
horizontalAccuracy: row[columnHorizontalAccuracy],
verticalAccuracy: row[columnHorizontalAccuracy],
course: row[columnCourse],
courseAccuracy: row[columnCourseAccuracy],
speed: row[columnSpeed],
speedAccuracy: row[columnSpeedAccuracy],
timestamp: .init(timeIntervalSinceReferenceDate: row[columnTimestamp]),
sourceInfo: .init())
}
static func createTable(in database: Connection) throws {
try database.execute("CREATE TABLE location_series_data (series_identifier INTEGER NOT NULL REFERENCES data_series(hfd_key) DEFERRABLE INITIALLY DEFERRED, timestamp REAL NOT NULL, longitude REAL NOT NULL, latitude REAL NOT NULL, altitude REAL NOT NULL, speed REAL NOT NULL, course REAL NOT NULL, horizontal_accuracy REAL NOT NULL, vertical_accuracy REAL NOT NULL, speed_accuracy REAL NOT NULL, course_accuracy REAL NOT NULL, signal_environment INTEGER NOT NULL, PRIMARY KEY (series_identifier, timestamp)) WITHOUT ROWID")
}
static func insert(_ sample: LocationSample, in database: Connection, seriesId: Int) throws {
try database.run(table.insert(
columnSeriesIdentifier <- seriesId,
columnTimestamp <- sample.timestamp.timeIntervalSinceReferenceDate,
columnLongitude <- sample.coordinate.longitude,
columnLatitude <- sample.coordinate.latitude,
columnAltitude <- sample.altitude,
columnSpeed <- sample.speed,
columnCourse <- sample.course,
columnHorizontalAccuracy <- sample.horizontalAccuracy,
columnHorizontalAccuracy <- sample.verticalAccuracy,
columnSpeedAccuracy <- sample.speedAccuracy,
columnCourseAccuracy <- sample.courseAccuracy,
columnSignalEnvironment <- 1
))
}
}

View File

@ -0,0 +1,44 @@
import Foundation
import SQLite
extension Sample {
private static let table = Table("samples")
private static let columnDataId = Expression<Int>("data_id")
// NOTE: Technically optional
private static let columnStartDate = Expression<Double>("start_date")
// NOTE: Technically optional
private static let columnEndDate = Expression<Double>("end_date")
private static let columnDataType = Expression<Int>("data_type")
static func samples(from start: Date, to end: Date, in database: Database) throws -> [Sample] {
let start = start.timeIntervalSinceReferenceDate
let end = end.timeIntervalSinceReferenceDate
return try database.prepare(table.filter(columnStartDate >= start && columnEndDate <= end)).compactMap { (row: Row) -> Sample? in
let dataId = row[columnDataId]
guard let quantity = try Sample.quantity(for: dataId, in: database) else {
return nil
}
let unit = try quantity.unit.map { try Sample.unit(for: $0, in: database) }
return Sample(
startDate: Date(timeIntervalSinceReferenceDate: row[columnStartDate]),
endDate: Date(timeIntervalSinceReferenceDate: row[columnEndDate]),
dataType: .init(rawValue: row[columnDataType]),
quantity: quantity.quantity,
originalQuantity: quantity.original,
originalUnit: unit)
}
}
static func sampleCount(from start: Date, to end: Date, in database: Database) throws -> Int {
let start = start.timeIntervalSinceReferenceDate
let end = end.timeIntervalSinceReferenceDate
return try database.scalar(table.filter(columnStartDate >= start && columnEndDate <= end).count)
}
}

View File

@ -1,33 +1,4 @@
import Foundation
import SQLite
enum SampleDataType: RawRepresentable {
case unknown(Int)
init(rawValue: Int) {
switch rawValue {
default:
self = .unknown(rawValue)
}
}
var rawValue: Int {
switch self {
case .unknown(let value):
return value
}
}
}
extension SampleDataType: Equatable {
}
extension SampleDataType: Hashable {
}
struct Sample {
@ -35,53 +6,117 @@ struct Sample {
let endDate: Date
let dataType: SampleDataType
let dataType: DataType
let quantity: Double
let originalQuantity: Double?
let originalUnit: String?
var duration: TimeInterval {
endDate.timeIntervalSince(startDate)
}
var originalQuantityText: String {
guard let originalQuantity, let originalUnit else {
return ""
}
return " (\(originalQuantity) \(originalUnit))"
}
}
extension Sample: CustomStringConvertible {
var description: String {
"\(startDate.timeAndDateText) (\(Int(duration)) s) \(quantity)\(originalQuantityText)"
}
}
extension Sample {
private static let table = Table("samples")
enum DataType: RawRepresentable {
private static let columnDataId = Expression<Int>("data_id")
case weight // 3
case heartRate // 5
case stepCount // 7
case distance // 8
case restingEnergy // 9
case activeEnergy // 10
case flightsClimed // 12
case weeklyCalorieGoal // 67
case watchOn // 70
case standMinutes // 75
case activity // 76
case workout // 79
// NOTE: Technically optional
private static let columnStartDate = Expression<Double>("start_date")
case unknown(Int)
// NOTE: Technically optional
private static let columnEndDate = Expression<Double>("end_date")
private static let columnDataType = Expression<Int>("data_type")
static func samples(from start: Date, to end: Date, in database: Database) throws -> [Sample] {
let start = start.timeIntervalSinceReferenceDate
let end = end.timeIntervalSinceReferenceDate
return try database.prepare(table.filter(columnStartDate >= start && columnEndDate <= end)).compactMap { (row: Row) -> Sample? in
let dataId = row[columnDataId]
guard let quantity = try Sample.quantity(for: dataId, in: database) else {
return nil
}
let unit = try quantity.unit.map { try Sample.unit(for: $0, in: database) }
return Sample(
startDate: Date(timeIntervalSinceReferenceDate: row[columnStartDate]),
endDate: Date(timeIntervalSinceReferenceDate: row[columnEndDate]),
dataType: .init(rawValue: row[columnDataType]),
quantity: quantity.quantity,
originalQuantity: quantity.original,
originalUnit: unit)
init(rawValue: Int) {
switch rawValue {
case 3: self = .weight
case 5: self = .heartRate
case 7: self = .stepCount
case 8: self = .distance
case 9: self = .restingEnergy
case 10: self = .activeEnergy
case 12: self = .flightsClimed
case 67: self = .weeklyCalorieGoal
case 70: self = .watchOn
case 75: self = .standMinutes
case 76: self = .activity
case 79: self = .workout
default:
self = .unknown(rawValue)
}
}
static func sampleCount(from start: Date, to end: Date, in database: Database) throws -> Int {
let start = start.timeIntervalSinceReferenceDate
let end = end.timeIntervalSinceReferenceDate
return try database.scalar(table.filter(columnStartDate >= start && columnEndDate <= end).count)
var rawValue: Int {
switch self {
case .stepCount: return 7
case .weight: return 3
case .heartRate: return 5
case .distance: return 8
case .restingEnergy: return 9
case .activeEnergy: return 10
case .flightsClimed: return 12
case .weeklyCalorieGoal: return 67
case .watchOn: return 70
case .standMinutes: return 75
case .activity: return 76
case .workout: return 79
case .unknown(let value): return value
}
}
}
}
extension Sample.DataType: Equatable {
}
extension Sample.DataType: Hashable {
}
extension Sample.DataType: CustomStringConvertible {
var description: String {
switch self {
case .stepCount: return "Step Count"
case .weight: return "Weight"
case .heartRate: return "Heart Rate"
case .distance: return "Distance"
case .restingEnergy: return "Resting Energy"
case .activeEnergy: return "Active Energy"
case .flightsClimed: return "Flights Climbed"
case .weeklyCalorieGoal: return "Weekly Calorie Goal"
case .watchOn: return "Watch On"
case .standMinutes: return "Stand Minutes"
case .activity: return "Activity"
case .workout: return "Workout"
case .unknown(let int): return "Unknown(\(int))"
}
}
}

View File

@ -52,9 +52,9 @@ struct WorkoutActivity {
try Sample.sampleCount(from: startDate, to: endDate, in: database)
}
func samples(in database: Database) throws -> [SampleDataType : Sample] {
func samples(in database: Database) throws -> [Sample.DataType : [Sample]] {
try Sample.samples(from: startDate, to: endDate, in: database).reduce(into: [:]) {
$0[$1.dataType] = $1
$0[$1.dataType] = ($0[$1.dataType] ?? []) + [$1]
}
}
}

View File

@ -0,0 +1,29 @@
import SwiftUI
struct SampleListView: View {
let type: Sample.DataType
let samples: [Sample]
var body: some View {
List {
ForEach(samples) { sample in
DetailRow("", value: sample)
}
}
.navigationTitle(type.description)
}
}
/*
#Preview {
SampleListView()
}
*/
extension Sample: Identifiable {
var id: Date {
startDate
}
}