sync current state

This commit is contained in:
2026-03-01 11:43:38 +01:00
parent 147ced281f
commit 4c8bcba514
12 changed files with 538 additions and 131 deletions

View File

@@ -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).

View File

@@ -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
}
}

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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")
}
}

View File

@@ -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`.