sync current state
This commit is contained in:
@@ -1,31 +0,0 @@
|
||||
# AI Agent Report
|
||||
|
||||
## Date
|
||||
- 2026-02-18
|
||||
- 2026-02-19
|
||||
|
||||
## Summary
|
||||
- Bootstrapped a new iOS SwiftUI app (`BulkHealth`) with HealthKit entitlement and localization scaffolding (`en`, `de`, `es`, `fr`).
|
||||
- Implemented a reusable import pattern (template fields + mapping + dry-run transform) and a Moodwell-specific adapter for Apple Health State of Mind.
|
||||
- Added dry-run preview and commit flow with HealthKit authorization/write for `HKStateOfMind` samples.
|
||||
- Added unit tests for Moodwell transformation logic and invalid date validation.
|
||||
- Verified app build and test-target build (`build-for-testing`) with local DerivedData.
|
||||
- Added generic field formatter scaffolding (passthrough/date pattern + optional regex pre-processing) and exposed it in UI for field-by-field mapping prep.
|
||||
- Added source-data previews per mapped field and formatting coverage (`mapped/total`) with highlighted partial coverage to support iterative import setup.
|
||||
- Refactored UX to navigation flow: app overview -> field list -> per-field mapping detail, reducing screen overload.
|
||||
- Added keyboard dismissal in field detail (`tap outside`, scroll-dismiss, keyboard toolbar `Done`).
|
||||
- Regenerated project and verified `build` + `build-for-testing` succeed after refactor.
|
||||
- Switched mapping model to Apple Health target fields (`date`, `valence`, `labels`, `associations`, `note`, `external_id`) instead of source-shaped fields.
|
||||
- Added target expectations per field (target format + allowed values) and made field list/detail target-first.
|
||||
- Simplified per-field detail UX: inline target explanation+example, date helper moved to footer, removed redundant selected-source header row, and fixed full-row tap targets for source key selection.
|
||||
- Reworked formatter screen to list available formatters with per-formatter match counts (`x/5`) and direct selection checkmarks, plus live validity preview rows for the currently selected formatter.
|
||||
- Updated localization copy to use Unicode formatter naming (`source → target`) and added explicit match-count label across `en/de/es/fr`.
|
||||
- Fixed ISO8601 date handling for Moodwell timestamps with fractional seconds (e.g., `.336Z`) in both formatter and validation paths; added regression test.
|
||||
- Future formatter strategy documented: when a formatter family has meaningful sub-variants (e.g., ISO8601 variants), model each common variant as a first-class selectable formatter instead of a hidden internal mode.
|
||||
|
||||
## Open Items
|
||||
- Verify UI/flow directly on iPhone with Health permissions and real Apple Health write behavior.
|
||||
- Expand mapping dictionaries for additional custom Moodwell emotions/activities if needed.
|
||||
- Add support for additional Apple Health data templates using the same import architecture.
|
||||
- In this sandbox, `xcodebuild test` cannot run because CoreSimulator service is unavailable.
|
||||
- Extend formatter types beyond date/string regex (e.g., unit normalization, numeric transforms, enum mapping tables).
|
||||
Binary file not shown.
@@ -2,52 +2,81 @@ import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@ObservedObject var viewModel: ImportFlowViewModel
|
||||
@State private var showingFileImporter = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
segmentSection
|
||||
sourceSection
|
||||
importSection
|
||||
}
|
||||
.navigationTitle(String(localized: "screen.title.import"))
|
||||
.fileImporter(
|
||||
isPresented: $showingFileImporter,
|
||||
allowedContentTypes: viewModel.supportedContentTypes,
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
switch result {
|
||||
case let .success(urls):
|
||||
if let url = urls.first {
|
||||
viewModel.loadFile(from: url)
|
||||
}
|
||||
case let .failure(error):
|
||||
viewModel.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
.alert(String(localized: "error.title"), isPresented: Binding(
|
||||
get: { viewModel.errorMessage != nil },
|
||||
set: { isVisible in
|
||||
if !isVisible {
|
||||
viewModel.errorMessage = nil
|
||||
List {
|
||||
NavigationLink {
|
||||
SegmentImportView(viewModel: viewModel)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(viewModel.template.localizedHealthTypeName)
|
||||
.font(.headline)
|
||||
Text(String(localized: "label.template"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
), actions: {
|
||||
Button(String(localized: "button.ok"), role: .cancel) {}
|
||||
}, message: {
|
||||
Text(viewModel.errorMessage ?? "")
|
||||
})
|
||||
}
|
||||
.navigationTitle(String(localized: "screen.title.segments"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var segmentSection: some View {
|
||||
Section(String(localized: "section.template")) {
|
||||
LabeledContent(String(localized: "label.health_segment"), value: viewModel.template.localizedHealthTypeName)
|
||||
NavigationLink(String(localized: "button.configure_fields")) {
|
||||
FieldMappingListView(viewModel: viewModel)
|
||||
private struct SegmentImportView: View {
|
||||
@ObservedObject var viewModel: ImportFlowViewModel
|
||||
@State private var showingFileImporter = false
|
||||
@State private var fieldStates: [String: ImportFlowViewModel.FieldMappingState] = [:]
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
sourceSection
|
||||
fieldsSection
|
||||
testImportSection
|
||||
}
|
||||
.navigationTitle(viewModel.template.localizedHealthTypeName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.fileImporter(
|
||||
isPresented: $showingFileImporter,
|
||||
allowedContentTypes: viewModel.supportedContentTypes,
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
switch result {
|
||||
case let .success(urls):
|
||||
if let url = urls.first {
|
||||
viewModel.loadFile(from: url)
|
||||
}
|
||||
case let .failure(error):
|
||||
viewModel.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
.alert(String(localized: "error.title"), isPresented: Binding(
|
||||
get: { viewModel.errorMessage != nil },
|
||||
set: { isVisible in
|
||||
if !isVisible {
|
||||
viewModel.errorMessage = nil
|
||||
}
|
||||
}
|
||||
), actions: {
|
||||
Button(String(localized: "button.ok"), role: .cancel) {}
|
||||
}, message: {
|
||||
Text(viewModel.errorMessage ?? "")
|
||||
})
|
||||
.onAppear {
|
||||
refreshFieldStates()
|
||||
}
|
||||
.onChange(of: viewModel.entries.count) { _, _ in
|
||||
refreshFieldStates()
|
||||
}
|
||||
.onChange(of: viewModel.mapping) { _, _ in
|
||||
refreshFieldStates()
|
||||
}
|
||||
.onChange(of: viewModel.fieldFormatters) { _, _ in
|
||||
refreshFieldStates()
|
||||
}
|
||||
.onChange(of: viewModel.moodValenceScale) { _, _ in
|
||||
refreshFieldStates()
|
||||
}
|
||||
}
|
||||
|
||||
private var sourceSection: some View {
|
||||
@@ -70,14 +99,102 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var importSection: some View {
|
||||
Section(String(localized: "section.commit")) {
|
||||
NavigationLink(String(localized: "button.open_import_plan")) {
|
||||
ImportPlanView(viewModel: viewModel)
|
||||
private var fieldsSection: some View {
|
||||
Section(String(localized: "section.mapping")) {
|
||||
ForEach(viewModel.template.fields) { field in
|
||||
NavigationLink {
|
||||
FieldMappingDetailView(viewModel: viewModel, field: field)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(field.title)
|
||||
Text(field.targetFormat)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(fieldStateText(fieldStates[field.id] ?? .incomplete))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(color(for: fieldStates[field.id] ?? .incomplete))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var testImportSection: some View {
|
||||
Section(String(localized: "section.commit")) {
|
||||
Button(String(localized: "button.run_dry_run")) {
|
||||
viewModel.runDryRun()
|
||||
}
|
||||
|
||||
if let result = viewModel.dryRunResult {
|
||||
Text(String.localizedStringWithFormat(
|
||||
NSLocalizedString("label.preview_count", comment: ""),
|
||||
result.drafts.count
|
||||
))
|
||||
if !result.errors.isEmpty {
|
||||
Text(String.localizedStringWithFormat(
|
||||
NSLocalizedString("label.errors_count", comment: ""),
|
||||
result.errors.count
|
||||
))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if !result.warnings.isEmpty {
|
||||
Text(String.localizedStringWithFormat(
|
||||
NSLocalizedString("label.warnings_count", comment: ""),
|
||||
result.warnings.count
|
||||
))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.commitImport()
|
||||
}
|
||||
} label: {
|
||||
if viewModel.isImporting {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text(String(localized: "button.commit"))
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isImporting)
|
||||
|
||||
Text(String(localized: "text.commit_notice"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func color(for state: ImportFlowViewModel.FieldMappingState) -> Color {
|
||||
switch state {
|
||||
case .incomplete:
|
||||
return .secondary
|
||||
case .partiallyValid:
|
||||
return .yellow
|
||||
case .valid:
|
||||
return .green
|
||||
}
|
||||
}
|
||||
|
||||
private func fieldStateText(_ state: ImportFlowViewModel.FieldMappingState) -> String {
|
||||
switch state {
|
||||
case .incomplete:
|
||||
return String(localized: "state.incomplete")
|
||||
case .partiallyValid:
|
||||
return String(localized: "state.partial")
|
||||
case .valid:
|
||||
return String(localized: "state.valid")
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshFieldStates() {
|
||||
var next: [String: ImportFlowViewModel.FieldMappingState] = [:]
|
||||
for field in viewModel.template.fields {
|
||||
next[field.id] = viewModel.fieldState(for: field)
|
||||
}
|
||||
fieldStates = next
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import SwiftUI
|
||||
|
||||
struct FieldMappingListView: View {
|
||||
@ObservedObject var viewModel: ImportFlowViewModel
|
||||
@State private var fieldStates: [String: ImportFlowViewModel.FieldMappingState] = [:]
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -9,6 +10,7 @@ struct FieldMappingListView: View {
|
||||
NavigationLink {
|
||||
FieldMappingDetailView(viewModel: viewModel, field: field)
|
||||
} label: {
|
||||
let state = fieldStates[field.id] ?? .incomplete
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(field.title)
|
||||
@@ -18,14 +20,29 @@ struct FieldMappingListView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(viewModel.fieldStateText(for: field))
|
||||
Text(fieldStateText(state))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(color(for: viewModel.fieldState(for: field)))
|
||||
.foregroundStyle(color(for: state))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "screen.title.field_mapping"))
|
||||
.onAppear {
|
||||
refreshFieldStates()
|
||||
}
|
||||
.onChange(of: viewModel.entries.count) { _, _ in
|
||||
refreshFieldStates()
|
||||
}
|
||||
.onChange(of: viewModel.mapping) { _, _ in
|
||||
refreshFieldStates()
|
||||
}
|
||||
.onChange(of: viewModel.fieldFormatters) { _, _ in
|
||||
refreshFieldStates()
|
||||
}
|
||||
.onChange(of: viewModel.moodValenceScale) { _, _ in
|
||||
refreshFieldStates()
|
||||
}
|
||||
}
|
||||
|
||||
private func color(for state: ImportFlowViewModel.FieldMappingState) -> Color {
|
||||
@@ -35,6 +52,25 @@ struct FieldMappingListView: View {
|
||||
case .valid: return .green
|
||||
}
|
||||
}
|
||||
|
||||
private func fieldStateText(_ state: ImportFlowViewModel.FieldMappingState) -> String {
|
||||
switch state {
|
||||
case .incomplete:
|
||||
return String(localized: "state.incomplete")
|
||||
case .partiallyValid:
|
||||
return String(localized: "state.partial")
|
||||
case .valid:
|
||||
return String(localized: "state.valid")
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshFieldStates() {
|
||||
var next: [String: ImportFlowViewModel.FieldMappingState] = [:]
|
||||
for field in viewModel.template.fields {
|
||||
next[field.id] = viewModel.fieldState(for: field)
|
||||
}
|
||||
fieldStates = next
|
||||
}
|
||||
}
|
||||
|
||||
struct FieldMappingDetailView: View {
|
||||
@@ -42,7 +78,7 @@ struct FieldMappingDetailView: View {
|
||||
let field: TemplateField
|
||||
|
||||
var body: some View {
|
||||
SwiftUI.Form {
|
||||
Form {
|
||||
targetSection
|
||||
sourceSection
|
||||
formatterSection
|
||||
@@ -117,33 +153,49 @@ struct FieldMappingDetailView: View {
|
||||
.foregroundStyle(viewModel.formattingPreview(for: field).isPartial ? .yellow : .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct FormatterSelectionView: View {
|
||||
@ObservedObject var viewModel: ImportFlowViewModel
|
||||
let field: TemplateField
|
||||
@State private var lookupDraft: [String: String] = [:]
|
||||
@State private var matchSummaryCache: [FieldFormatterType: (matched: Int, total: Int)] = [:]
|
||||
|
||||
var body: some View {
|
||||
let formatterOptions = viewModel.formatterOptions(for: field)
|
||||
let selectedFormatter = viewModel.formatter(for: field.id)
|
||||
let selectedFormatterType = selectedFormatter.formatterType
|
||||
let previewRows = selectedFormatterType == .integerLookupToNumber ? [] : viewModel.previewRows(for: field, limit: 5)
|
||||
let previewMatchedCount = previewRows.filter { $0.valid }.count
|
||||
let previewCoverageColor: Color = previewRows.isEmpty ? .secondary : (previewMatchedCount == previewRows.count ? .green : .yellow)
|
||||
let sourceHistogram = selectedFormatterType == .integerLookupToNumber ? viewModel.sourceValueHistogram(for: field) : []
|
||||
let lookupCoverage = lookupCoverage(from: sourceHistogram)
|
||||
|
||||
Form {
|
||||
Section(header: Text(String(localized: "section.formatting"))) {
|
||||
ForEach(viewModel.formatterOptions(for: field)) { formatterType in
|
||||
ForEach(formatterOptions) { formatterType in
|
||||
Button {
|
||||
if selectedFormatterType == .integerLookupToNumber {
|
||||
syncLookupDraftToModel()
|
||||
}
|
||||
viewModel.updateFormatter(for: field.id) { $0.formatterType = formatterType }
|
||||
if formatterType == .integerLookupToNumber {
|
||||
loadLookupDraftFromModel()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(viewModel.formatterDisplayName(formatterType))
|
||||
Text(String.localizedStringWithFormat(
|
||||
NSLocalizedString("label.formatter_matches", comment: ""),
|
||||
viewModel.formatterMatchSummary(for: field, formatterType: formatterType).matched,
|
||||
viewModel.formatterMatchSummary(for: field, formatterType: formatterType).total
|
||||
matchSummaryCache[formatterType]?.matched ?? 0,
|
||||
matchSummaryCache[formatterType]?.total ?? 0
|
||||
))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(formatterMatchColor(for: formatterType))
|
||||
.foregroundStyle(matchColor(for: formatterType, summary: matchSummaryCache))
|
||||
}
|
||||
Spacer()
|
||||
if viewModel.formatter(for: field.id).formatterType == formatterType {
|
||||
if selectedFormatterType == formatterType {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
@@ -155,53 +207,172 @@ struct FormatterSelectionView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text(String(localized: "section.test_area"))) {
|
||||
Text(String.localizedStringWithFormat(
|
||||
NSLocalizedString("label.formatter_matches", comment: ""),
|
||||
previewMatchedCount,
|
||||
previewRows.count
|
||||
))
|
||||
.font(.caption)
|
||||
.foregroundStyle(previewCoverageColor)
|
||||
if selectedFormatterType == .integerLookupToNumber {
|
||||
Section(
|
||||
header: Text(String(localized: "section.value_mapping")),
|
||||
footer: Text(String(localized: "text.value_mapping_footer"))
|
||||
) {
|
||||
if sourceHistogram.isEmpty {
|
||||
Text(String(localized: "text.no_source_values"))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(sourceHistogram, id: \.value) { item in
|
||||
HStack(spacing: 12) {
|
||||
Text(item.value)
|
||||
.frame(width: 44, alignment: .leading)
|
||||
Text("x\(item.count)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
TextField(
|
||||
String(localized: "placeholder.target_number"),
|
||||
text: bindingForMappedValue(item.value)
|
||||
)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.keyboardType(.default)
|
||||
.frame(maxWidth: 120)
|
||||
statusIcon(for: item.value)
|
||||
}
|
||||
}
|
||||
|
||||
if previewRows.isEmpty {
|
||||
Text(String(localized: "text.no_source_values"))
|
||||
Text(String.localizedStringWithFormat(
|
||||
NSLocalizedString("label.lookup_distinct_progress", comment: ""),
|
||||
lookupCoverage.mappedDistinct,
|
||||
lookupCoverage.totalDistinct
|
||||
))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(Array(previewRows.enumerated()), id: \.offset) { _, row in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(row.input)
|
||||
.font(.caption)
|
||||
Text(row.output.isEmpty ? String(localized: "text.formatter_empty") : row.output)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(row.valid ? .green : .red)
|
||||
|
||||
Text(String.localizedStringWithFormat(
|
||||
NSLocalizedString("label.lookup_rows_progress", comment: ""),
|
||||
lookupCoverage.mappedRows,
|
||||
lookupCoverage.totalRows
|
||||
))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Section(header: Text(String(localized: "section.test_area"))) {
|
||||
Text(String.localizedStringWithFormat(
|
||||
NSLocalizedString("label.formatter_matches", comment: ""),
|
||||
previewMatchedCount,
|
||||
previewRows.count
|
||||
))
|
||||
.font(.caption)
|
||||
.foregroundStyle(previewCoverageColor)
|
||||
|
||||
if previewRows.isEmpty {
|
||||
Text(String(localized: "text.no_source_values"))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(Array(previewRows.enumerated()), id: \.offset) { _, row in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(row.input)
|
||||
.font(.caption)
|
||||
Text(row.output.isEmpty ? String(localized: "text.formatter_empty") : row.output)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(row.valid ? .green : .red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "screen.title.formatter"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var previewRows: [(input: String, output: String, valid: Bool)] {
|
||||
viewModel.previewRows(for: field, limit: 5)
|
||||
}
|
||||
|
||||
private var previewMatchedCount: Int {
|
||||
previewRows.filter { $0.valid }.count
|
||||
}
|
||||
|
||||
private var previewCoverageColor: Color {
|
||||
if previewRows.isEmpty {
|
||||
return .secondary
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
refreshMatchSummary()
|
||||
if selectedFormatterType == .integerLookupToNumber {
|
||||
loadLookupDraftFromModel()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
syncLookupDraftToModel()
|
||||
}
|
||||
return previewMatchedCount == previewRows.count ? .green : .yellow
|
||||
}
|
||||
|
||||
private func formatterMatchColor(for formatterType: FieldFormatterType) -> Color {
|
||||
let summary = viewModel.formatterMatchSummary(for: field, formatterType: formatterType)
|
||||
return summary.matched == summary.total && summary.total > 0 ? .green : .secondary
|
||||
private func matchColor(
|
||||
for formatterType: FieldFormatterType,
|
||||
summary: [FieldFormatterType: (matched: Int, total: Int)]
|
||||
) -> Color {
|
||||
let item = summary[formatterType] ?? (0, 0)
|
||||
return item.matched == item.total && item.total > 0 ? .green : .secondary
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusIcon(for sourceValue: String) -> some View {
|
||||
let mapped = lookupDraft[sourceValue]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if mapped.isEmpty {
|
||||
Image(systemName: "circle")
|
||||
.foregroundStyle(.secondary)
|
||||
} else if viewModel.isValidTargetValue(for: field, value: mapped) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
}
|
||||
|
||||
private func bindingForMappedValue(_ sourceValue: String) -> Binding<String> {
|
||||
Binding(
|
||||
get: {
|
||||
lookupDraft[sourceValue, default: ""]
|
||||
},
|
||||
set: { newValue in
|
||||
lookupDraft[sourceValue] = newValue
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func loadLookupDraftFromModel() {
|
||||
lookupDraft = viewModel.formatter(for: field.id).integerLookup
|
||||
}
|
||||
|
||||
private func syncLookupDraftToModel() {
|
||||
viewModel.updateFormatter(for: field.id) { config in
|
||||
var cleaned: [String: String] = [:]
|
||||
for (key, value) in lookupDraft {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
cleaned[key] = trimmed
|
||||
}
|
||||
}
|
||||
config.integerLookup = cleaned
|
||||
}
|
||||
refreshMatchSummary()
|
||||
}
|
||||
|
||||
private func lookupCoverage(from histogram: [(value: String, count: Int)]) -> (mappedDistinct: Int, totalDistinct: Int, mappedRows: Int, totalRows: Int) {
|
||||
let totalRows = histogram.reduce(0) { $0 + $1.count }
|
||||
guard !histogram.isEmpty else {
|
||||
return (0, 0, 0, 0)
|
||||
}
|
||||
|
||||
var mappedDistinct = 0
|
||||
var mappedRows = 0
|
||||
|
||||
for item in histogram {
|
||||
let mapped = lookupDraft[item.value]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !mapped.isEmpty else {
|
||||
continue
|
||||
}
|
||||
if viewModel.isValidTargetValue(for: field, value: mapped) {
|
||||
mappedDistinct += 1
|
||||
mappedRows += item.count
|
||||
}
|
||||
}
|
||||
|
||||
return (mappedDistinct, histogram.count, mappedRows, totalRows)
|
||||
}
|
||||
|
||||
private func refreshMatchSummary() {
|
||||
var cache: [FieldFormatterType: (matched: Int, total: Int)] = [:]
|
||||
for formatterType in viewModel.formatterOptions(for: field) {
|
||||
cache[formatterType] = viewModel.formatterMatchSummary(for: field, formatterType: formatterType)
|
||||
}
|
||||
matchSummaryCache = cache
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +380,7 @@ struct ImportPlanView: View {
|
||||
@ObservedObject var viewModel: ImportFlowViewModel
|
||||
|
||||
var body: some View {
|
||||
SwiftUI.Form {
|
||||
Form {
|
||||
Section(String(localized: "section.plan_summary")) {
|
||||
let summary = viewModel.mappingSummary()
|
||||
Text(String.localizedStringWithFormat(
|
||||
|
||||
@@ -194,6 +194,11 @@ final class ImportFlowViewModel: ObservableObject {
|
||||
.dateGermanDayMonthYearHourMinuteToISO8601,
|
||||
.dateUnixSecondsToISO8601
|
||||
]
|
||||
case .number:
|
||||
return [
|
||||
.passthrough,
|
||||
.integerLookupToNumber
|
||||
]
|
||||
default:
|
||||
return [.passthrough]
|
||||
}
|
||||
@@ -203,6 +208,8 @@ final class ImportFlowViewModel: ObservableObject {
|
||||
switch type {
|
||||
case .passthrough:
|
||||
return String(localized: "formatter.option.passthrough")
|
||||
case .integerLookupToNumber:
|
||||
return String(localized: "formatter.option.integer_lookup")
|
||||
case .dateISO8601ToISO8601:
|
||||
return String(localized: "formatter.option.date_iso")
|
||||
case .dateGermanDayMonthYearToISO8601:
|
||||
@@ -214,6 +221,89 @@ final class ImportFlowViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func distinctSourceValues(for field: TemplateField) -> [String] {
|
||||
let sourceKey = mappedSourceKey(for: field.id)
|
||||
guard !sourceKey.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
let values = entries
|
||||
.compactMap { $0.sourceDictionary[sourceKey]?.sourceString() }
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
let unique = Array(Set(values))
|
||||
|
||||
return unique.sorted { lhs, rhs in
|
||||
if let left = Double(lhs), let right = Double(rhs) {
|
||||
return left < right
|
||||
}
|
||||
return lhs.localizedCaseInsensitiveCompare(rhs) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
func sourceValueHistogram(for field: TemplateField) -> [(value: String, count: Int)] {
|
||||
let sourceKey = mappedSourceKey(for: field.id)
|
||||
guard !sourceKey.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
var counts: [String: Int] = [:]
|
||||
for entry in entries {
|
||||
guard let value = entry.sourceDictionary[sourceKey]?.sourceString() else {
|
||||
continue
|
||||
}
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
continue
|
||||
}
|
||||
counts[trimmed, default: 0] += 1
|
||||
}
|
||||
|
||||
return counts
|
||||
.map { (key: $0.key, value: $0.value) }
|
||||
.sorted { lhs, rhs in
|
||||
if let left = Double(lhs.key), let right = Double(rhs.key) {
|
||||
return left < right
|
||||
}
|
||||
return lhs.key.localizedCaseInsensitiveCompare(rhs.key) == .orderedAscending
|
||||
}
|
||||
.map { (value: $0.key, count: $0.value) }
|
||||
}
|
||||
|
||||
func isValidTargetValue(for field: TemplateField, value: String) -> Bool {
|
||||
MoodwellStateOfMindTemplate.validateFormattedTarget(
|
||||
fieldID: field.id,
|
||||
formatted: value,
|
||||
moodValenceScale: moodValenceScale
|
||||
)
|
||||
}
|
||||
|
||||
func integerLookupCoverage(for field: TemplateField) -> (mappedDistinct: Int, totalDistinct: Int, mappedRows: Int, totalRows: Int) {
|
||||
let histogram = sourceValueHistogram(for: field)
|
||||
let totalRows = histogram.reduce(0) { $0 + $1.count }
|
||||
let formatter = formatter(for: field.id)
|
||||
|
||||
guard formatter.formatterType == .integerLookupToNumber else {
|
||||
return (0, histogram.count, 0, totalRows)
|
||||
}
|
||||
|
||||
var mappedDistinct = 0
|
||||
var mappedRows = 0
|
||||
|
||||
for item in histogram {
|
||||
guard let mapped = formatter.integerLookup[item.value]?.trimmingCharacters(in: .whitespacesAndNewlines), !mapped.isEmpty else {
|
||||
continue
|
||||
}
|
||||
if isValidTargetValue(for: field, value: mapped) {
|
||||
mappedDistinct += 1
|
||||
mappedRows += item.count
|
||||
}
|
||||
}
|
||||
|
||||
return (mappedDistinct, histogram.count, mappedRows, totalRows)
|
||||
}
|
||||
|
||||
func fieldState(for field: TemplateField) -> FieldMappingState {
|
||||
let sourceKey = mappedSourceKey(for: field.id)
|
||||
guard !sourceKey.isEmpty else {
|
||||
|
||||
@@ -37,6 +37,7 @@ struct ImportMapping: Equatable {
|
||||
|
||||
enum FieldFormatterType: String, CaseIterable, Identifiable {
|
||||
case passthrough
|
||||
case integerLookupToNumber
|
||||
case dateISO8601ToISO8601
|
||||
case dateGermanDayMonthYearToISO8601
|
||||
case dateGermanDayMonthYearHourMinuteToISO8601
|
||||
@@ -47,6 +48,7 @@ enum FieldFormatterType: String, CaseIterable, Identifiable {
|
||||
|
||||
struct FieldFormatterConfig: Equatable {
|
||||
var formatterType: FieldFormatterType
|
||||
var integerLookup: [String: String] = [:]
|
||||
|
||||
static func `default`(for fieldType: TemplateField.FieldType) -> FieldFormatterConfig {
|
||||
if fieldType == .date {
|
||||
@@ -160,26 +162,47 @@ enum ImportValue: Hashable {
|
||||
|
||||
enum ImportFieldFormatterEngine {
|
||||
static func apply(sourceText: String, config: FieldFormatterConfig) -> String? {
|
||||
let trimmed = sourceText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
switch config.formatterType {
|
||||
case .passthrough:
|
||||
return sourceText
|
||||
case .integerLookupToNumber:
|
||||
return applyIntegerLookup(sourceText: trimmed, config: config)
|
||||
case .dateISO8601ToISO8601:
|
||||
guard let date = parseISO8601(sourceText.trimmingCharacters(in: .whitespacesAndNewlines)) else {
|
||||
guard let date = parseISO8601(trimmed) else {
|
||||
return nil
|
||||
}
|
||||
return iso8601String(from: date)
|
||||
case .dateGermanDayMonthYearToISO8601:
|
||||
return convertDate(sourceText, sourcePattern: "dd.MM.yyyy")
|
||||
return convertDate(trimmed, sourcePattern: "dd.MM.yyyy")
|
||||
case .dateGermanDayMonthYearHourMinuteToISO8601:
|
||||
return convertDate(sourceText, sourcePattern: "dd.MM.yyyy HH:mm")
|
||||
return convertDate(trimmed, sourcePattern: "dd.MM.yyyy HH:mm")
|
||||
case .dateUnixSecondsToISO8601:
|
||||
guard let seconds = Double(sourceText.trimmingCharacters(in: .whitespacesAndNewlines)) else {
|
||||
guard let seconds = Double(trimmed) else {
|
||||
return nil
|
||||
}
|
||||
return iso8601String(from: Date(timeIntervalSince1970: seconds))
|
||||
}
|
||||
}
|
||||
|
||||
private static func applyIntegerLookup(sourceText: String, config: FieldFormatterConfig) -> String? {
|
||||
if let mapped = config.integerLookup[sourceText]?.trimmingCharacters(in: .whitespacesAndNewlines), !mapped.isEmpty {
|
||||
return mapped
|
||||
}
|
||||
|
||||
if
|
||||
let number = Double(sourceText),
|
||||
number.rounded() == number
|
||||
{
|
||||
let integerKey = String(Int(number))
|
||||
if let mapped = config.integerLookup[integerKey]?.trimmingCharacters(in: .whitespacesAndNewlines), !mapped.isEmpty {
|
||||
return mapped
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func convertDate(_ input: String, sourcePattern: String) -> String? {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
|
||||
@@ -125,3 +125,10 @@
|
||||
"formatter.option.date_ddmmyyyy" = "dd.MM.yyyy → ISO8601";
|
||||
"formatter.option.date_ddmmyyyy_hhmm" = "dd.MM.yyyy HH:mm → ISO8601";
|
||||
"formatter.option.date_unix_seconds" = "Unix-Sekunden → ISO8601";
|
||||
"formatter.option.integer_lookup" = "Integer-Wert → Zielzahl";
|
||||
"section.value_mapping" = "Werte-Mapping";
|
||||
"placeholder.target_number" = "Ziel";
|
||||
"text.value_mapping_footer" = "Mappe jeden unterschiedlichen Quellwert auf eine Zielzahl im erlaubten Bereich.";
|
||||
"label.lookup_distinct_progress" = "Unterschiedliche Werte gemappt: %lld/%lld";
|
||||
"label.lookup_rows_progress" = "Zeilen durch Mapping abgedeckt: %lld/%lld";
|
||||
"screen.title.segments" = "Health-Segmente";
|
||||
|
||||
@@ -125,3 +125,10 @@
|
||||
"formatter.option.date_ddmmyyyy" = "dd.MM.yyyy → ISO8601";
|
||||
"formatter.option.date_ddmmyyyy_hhmm" = "dd.MM.yyyy HH:mm → ISO8601";
|
||||
"formatter.option.date_unix_seconds" = "Unix seconds → ISO8601";
|
||||
"formatter.option.integer_lookup" = "Integer value → target number";
|
||||
"section.value_mapping" = "Value Mapping";
|
||||
"placeholder.target_number" = "Target";
|
||||
"text.value_mapping_footer" = "Map each distinct source value to a target number in the allowed range.";
|
||||
"label.lookup_distinct_progress" = "Distinct values mapped: %lld/%lld";
|
||||
"label.lookup_rows_progress" = "Rows covered by mapping: %lld/%lld";
|
||||
"screen.title.segments" = "Health Segments";
|
||||
|
||||
@@ -125,3 +125,10 @@
|
||||
"formatter.option.date_ddmmyyyy" = "dd.MM.yyyy → ISO8601";
|
||||
"formatter.option.date_ddmmyyyy_hhmm" = "dd.MM.yyyy HH:mm → ISO8601";
|
||||
"formatter.option.date_unix_seconds" = "Segundos Unix → ISO8601";
|
||||
"formatter.option.integer_lookup" = "Valor entero → número objetivo";
|
||||
"section.value_mapping" = "Mapeo de valores";
|
||||
"placeholder.target_number" = "Objetivo";
|
||||
"text.value_mapping_footer" = "Asigna cada valor de origen distinto a un número objetivo dentro del rango permitido.";
|
||||
"label.lookup_distinct_progress" = "Valores distintos mapeados: %lld/%lld";
|
||||
"label.lookup_rows_progress" = "Filas cubiertas por el mapeo: %lld/%lld";
|
||||
"screen.title.segments" = "Segmentos de Health";
|
||||
|
||||
@@ -125,3 +125,10 @@
|
||||
"formatter.option.date_ddmmyyyy" = "dd.MM.yyyy → ISO8601";
|
||||
"formatter.option.date_ddmmyyyy_hhmm" = "dd.MM.yyyy HH:mm → ISO8601";
|
||||
"formatter.option.date_unix_seconds" = "Secondes Unix → ISO8601";
|
||||
"formatter.option.integer_lookup" = "Valeur entière → nombre cible";
|
||||
"section.value_mapping" = "Mapping des valeurs";
|
||||
"placeholder.target_number" = "Cible";
|
||||
"text.value_mapping_footer" = "Mappez chaque valeur source distincte vers un nombre cible dans la plage autorisée.";
|
||||
"label.lookup_distinct_progress" = "Valeurs distinctes mappées : %lld/%lld";
|
||||
"label.lookup_rows_progress" = "Lignes couvertes par le mapping : %lld/%lld";
|
||||
"screen.title.segments" = "Segments Health";
|
||||
|
||||
@@ -66,4 +66,23 @@ struct BulkHealthTests {
|
||||
#expect(output != nil)
|
||||
#expect(output == "2020-06-03T19:52:27Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
func integerLookupFormatterMapsDiscreteValues() {
|
||||
let output = ImportFieldFormatterEngine.apply(
|
||||
sourceText: "3",
|
||||
config: FieldFormatterConfig(
|
||||
formatterType: .integerLookupToNumber,
|
||||
integerLookup: [
|
||||
"0": "-1.0",
|
||||
"1": "-0.5",
|
||||
"2": "0.0",
|
||||
"3": "0.5",
|
||||
"4": "1.0"
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
#expect(output == "0.5")
|
||||
}
|
||||
}
|
||||
|
||||
10
LEARNINGS.md
10
LEARNINGS.md
@@ -1,10 +0,0 @@
|
||||
# Learnings
|
||||
|
||||
- `HKStateOfMind` write APIs are directly available on iOS 18+ and can be created with date/kind/valence/labels/associations in one call.
|
||||
- Moodwell exports can include large arrays and sparse optional fields, so dry-run validation is necessary before committing to HealthKit.
|
||||
- Headless/sandboxed `xcodebuild` may fail SwiftUI `#Preview` macro expansion, so keeping previews out of CI-oriented builds avoids false negatives.
|
||||
- In `.strings` files, `%@` with integer arguments in `String(format:)` can trigger `EXC_BAD_ACCESS`; use numeric specifiers like `%ld` and `String.localizedStringWithFormat`.
|
||||
- Showing `mapped/total` formatter coverage per field in the mapping UI quickly exposes mismatched date patterns before dry-run.
|
||||
- In SwiftUI `ForEach`, using value-based IDs for non-unique sample data causes duplicate-ID runtime warnings; index-based IDs avoid undefined list behavior.
|
||||
- For full-width row taps with `.buttonStyle(.plain)` inside `Form`, set both `.frame(maxWidth: .infinity, alignment: .leading)` and `.contentShape(Rectangle())`.
|
||||
- `ISO8601DateFormatter()` with default options does not parse fractional seconds (`.SSS`); use a formatter with `.withFractionalSeconds` and fallback to `.withInternetDateTime`.
|
||||
Reference in New Issue
Block a user