refine field mapping ux and fix iso8601 fractional-second parsing

This commit is contained in:
2026-02-19 12:45:52 +01:00
parent 2c5b5c1767
commit 147ced281f
13 changed files with 1156 additions and 199 deletions

View File

@@ -2,6 +2,7 @@
## 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`).
@@ -9,9 +10,22 @@
- 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

@@ -13,6 +13,7 @@
1E6C5AFD3A8F328D7F12ADE2 /* MoodwellData.json in Resources */ = {isa = PBXBuildFile; fileRef = 6244F521D755A682362FE647 /* MoodwellData.json */; };
2CF905CDCD4E76F59990DA53 /* BulkHealthApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BB40556E06CA90DF78CE4 /* BulkHealthApp.swift */; };
60A88E25306111B096760B43 /* ImportFlowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7CEDF1D80E0D342B994343 /* ImportFlowViewModel.swift */; };
89BDD8ED14F6B6176F9F885B /* FieldMappingViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EEAEB6E0C1BBFD3C368B4CA /* FieldMappingViews.swift */; };
B24496C3D0C604C99D3F079B /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3BD4AD38428A5AAA87D628C3 /* Localizable.strings */; };
C9D09732E786624DDD7DA520 /* MoodwellStateOfMindTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EE805B262EBB414E1BAF60B /* MoodwellStateOfMindTemplate.swift */; };
CD727A4847E448D49660C34B /* BulkHealthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 157B89F92D6B26E186902D96 /* BulkHealthTests.swift */; };
@@ -37,6 +38,7 @@
6244F521D755A682362FE647 /* MoodwellData.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MoodwellData.json; sourceTree = "<group>"; };
7C315594F7577615AE4299D6 /* ImportModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportModels.swift; sourceTree = "<group>"; };
7EE805B262EBB414E1BAF60B /* MoodwellStateOfMindTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodwellStateOfMindTemplate.swift; sourceTree = "<group>"; };
7EEAEB6E0C1BBFD3C368B4CA /* FieldMappingViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldMappingViews.swift; sourceTree = "<group>"; };
8E2D77BAE6A93846EBB69369 /* BulkHealth.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = BulkHealth.app; sourceTree = BUILT_PRODUCTS_DIR; };
9BB95915C4BA5670D348348E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
A4F08640D1164270AB69287E /* BulkHealthTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = BulkHealthTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -71,6 +73,7 @@
3871CE97DA417A2FE4031C4B /* BulkHealth.entitlements */,
4C3BB40556E06CA90DF78CE4 /* BulkHealthApp.swift */,
C58AB725F0E46CEA7B16BC12 /* ContentView.swift */,
7EEAEB6E0C1BBFD3C368B4CA /* FieldMappingViews.swift */,
9BB95915C4BA5670D348348E /* Info.plist */,
E721EFE7386687716D780807 /* Import */,
11E0721B8A4A36711DBD470E /* Resources */,
@@ -215,6 +218,7 @@
files = (
2CF905CDCD4E76F59990DA53 /* BulkHealthApp.swift in Sources */,
102F2185E6873FE20EBC0392 /* ContentView.swift in Sources */,
89BDD8ED14F6B6176F9F885B /* FieldMappingViews.swift in Sources */,
1725244D3318135F3A349E1B /* HealthKitService.swift in Sources */,
60A88E25306111B096760B43 /* ImportFlowViewModel.swift in Sources */,
0B7694AE0FE28B642DF7D0EA /* ImportModels.swift in Sources */,

View File

@@ -7,12 +7,9 @@ struct ContentView: View {
var body: some View {
NavigationStack {
Form {
templateSection
segmentSection
sourceSection
mappingSection
moodScaleSection
dryRunSection
commitSection
importSection
}
.navigationTitle(String(localized: "screen.title.import"))
.fileImporter(
@@ -44,35 +41,27 @@ struct ContentView: View {
}
}
private var templateSection: some View {
private var segmentSection: some View {
Section(String(localized: "section.template")) {
LabeledContent(String(localized: "label.health_segment"), value: viewModel.template.localizedHealthTypeName)
LabeledContent(String(localized: "label.template"), value: viewModel.template.title)
ForEach(viewModel.template.fields) { field in
VStack(alignment: .leading, spacing: 4) {
Text(field.title)
.font(.headline)
Text(field.description)
.font(.caption)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .combine)
NavigationLink(String(localized: "button.configure_fields")) {
FieldMappingListView(viewModel: viewModel)
}
}
}
private var sourceSection: some View {
Section(String(localized: "section.source")) {
Section(String(localized: "section.source")) {
Button(String(localized: "button.load_json")) {
showingFileImporter = true
}
Button(String(localized: "button.load_sample")) {
viewModel.loadBundledSample()
}
Text(String.localizedStringWithFormat(
NSLocalizedString("label.records_loaded", comment: ""),
viewModel.entries.count
))
Text(String.localizedStringWithFormat(
NSLocalizedString("label.records_loaded", comment: ""),
viewModel.entries.count
))
if !viewModel.statusMessage.isEmpty {
Text(viewModel.statusMessage)
.font(.caption)
@@ -81,108 +70,14 @@ struct ContentView: View {
}
}
private var mappingSection: some View {
Section(String(localized: "section.mapping")) {
if viewModel.sourceKeys.isEmpty {
Text(String(localized: "text.mapping.unavailable"))
foregroundStyle(.secondary)
} else {
ForEach(viewModel.template.fields) { field in
Picker(field.title, selection: Binding(
get: { viewModel.mapping.sourceKey(for: field.id) },
set: { newValue in
viewModel.mapping.sourceKeyByFieldID[field.id] = newValue
}
)) {
ForEach(mappingOptions(for: field), id: \.self) { sourceKey in
Text(sourceKey).tag(sourceKey)
}
}
}
}
}
}
private var moodScaleSection: some View {
Section(String(localized: "section.scale")) {
ForEach([1, 2, 3, 4], id: \.self) { moodType in
HStack {
Text(String.localizedStringWithFormat(
NSLocalizedString("label.mood_type_scale", comment: ""),
moodType
))
Spacer()
Text(String(format: "%.2f", viewModel.moodValenceScale[moodType, default: 0]))
}
Slider(value: Binding(
get: { viewModel.moodValenceScale[moodType, default: 0] },
set: { viewModel.moodValenceScale[moodType] = $0 }
), in: -1...1, step: 0.05)
.accessibilityLabel(Text("Mood type \(moodType) valence"))
}
}
}
private var dryRunSection: some View {
Section(String(localized: "section.dry_run")) {
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)
}
ForEach(Array(result.drafts.prefix(15))) { draft in
Text(draft.summaryText)
.font(.caption)
}
}
}
}
private var commitSection: some View {
private var importSection: some View {
Section(String(localized: "section.commit")) {
Button {
Task {
await viewModel.commitImport()
}
} label: {
if viewModel.isImporting {
ProgressView()
} else {
Text(String(localized: "button.commit"))
}
NavigationLink(String(localized: "button.open_import_plan")) {
ImportPlanView(viewModel: viewModel)
}
.disabled(viewModel.isImporting)
Text(String(localized: "text.commit_notice"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func mappingOptions(for field: TemplateField) -> [String] {
let selected = viewModel.mapping.sourceKey(for: field.id)
if selected.isEmpty {
return viewModel.sourceKeys
}
if viewModel.sourceKeys.contains(selected) {
return viewModel.sourceKeys
}
return [selected] + viewModel.sourceKeys
}
}

View File

@@ -0,0 +1,285 @@
import SwiftUI
struct FieldMappingListView: View {
@ObservedObject var viewModel: ImportFlowViewModel
var body: some View {
List {
ForEach(viewModel.template.fields) { field in
NavigationLink {
FieldMappingDetailView(viewModel: viewModel, field: field)
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(field.title)
.font(.headline)
Text(field.targetFormat)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(viewModel.fieldStateText(for: field))
.font(.caption2)
.foregroundStyle(color(for: viewModel.fieldState(for: field)))
}
}
}
}
.navigationTitle(String(localized: "screen.title.field_mapping"))
}
private func color(for state: ImportFlowViewModel.FieldMappingState) -> Color {
switch state {
case .incomplete: return .secondary
case .partiallyValid: return .yellow
case .valid: return .green
}
}
}
struct FieldMappingDetailView: View {
@ObservedObject var viewModel: ImportFlowViewModel
let field: TemplateField
var body: some View {
SwiftUI.Form {
targetSection
sourceSection
formatterSection
}
.navigationTitle(field.title)
.navigationBarTitleDisplayMode(.inline)
}
private var targetSection: some View {
Section(
header: Text(String(localized: "section.target")),
footer: Text(field.id == "date" ? String(localized: "field.date.helper") : "")
) {
Text(
field.targetExplanation + " " + String.localizedStringWithFormat(
NSLocalizedString("label.example_value", comment: ""),
field.targetExample
)
)
if let allowed = field.targetAllowedValues, !allowed.isEmpty {
Text(allowed)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
private var sourceSection: some View {
Section(header: Text(String(localized: "section.mapping"))) {
if viewModel.sourceKeys.isEmpty {
Text(String(localized: "text.mapping.unavailable"))
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.sourceKeys, id: \.self) { sourceKey in
Button {
viewModel.mapping.sourceKeyByFieldID[field.id] = sourceKey
} label: {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(sourceKey)
Spacer()
if viewModel.mappedSourceKey(for: field.id) == sourceKey {
Image(systemName: "checkmark")
.foregroundStyle(.green)
}
}
Text(viewModel.sourceExample(for: sourceKey))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
}
}
private var formatterSection: some View {
Section(header: Text(String(localized: "section.formatting"))) {
NavigationLink(String(localized: "button.select_formatter")) {
FormatterSelectionView(viewModel: viewModel, field: field)
}
Text(String.localizedStringWithFormat(
NSLocalizedString("label.formatter_coverage", comment: ""),
viewModel.formattingPreview(for: field).matched,
viewModel.formattingPreview(for: field).total
))
.foregroundStyle(viewModel.formattingPreview(for: field).isPartial ? .yellow : .secondary)
}
}
}
struct FormatterSelectionView: View {
@ObservedObject var viewModel: ImportFlowViewModel
let field: TemplateField
var body: some View {
Form {
Section(header: Text(String(localized: "section.formatting"))) {
ForEach(viewModel.formatterOptions(for: field)) { formatterType in
Button {
viewModel.updateFormatter(for: field.id) { $0.formatterType = formatterType }
} 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
))
.font(.caption2)
.foregroundStyle(formatterMatchColor(for: formatterType))
}
Spacer()
if viewModel.formatter(for: field.id).formatterType == formatterType {
Image(systemName: "checkmark")
.foregroundStyle(.green)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
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
}
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
}
}
struct ImportPlanView: View {
@ObservedObject var viewModel: ImportFlowViewModel
var body: some View {
SwiftUI.Form {
Section(String(localized: "section.plan_summary")) {
let summary = viewModel.mappingSummary()
Text(String.localizedStringWithFormat(
NSLocalizedString("label.fields_ready", comment: ""),
summary.validFields,
summary.totalFields
))
Text(String.localizedStringWithFormat(
NSLocalizedString("label.rows_ready", comment: ""),
summary.validRows,
summary.totalRows
))
}
Section(String(localized: "section.template")) {
ForEach(viewModel.template.fields) { field in
VStack(alignment: .leading, spacing: 4) {
Text(field.title)
.font(.headline)
Text(String.localizedStringWithFormat(
NSLocalizedString("label.source_for_field", comment: ""),
viewModel.mappedSourceKey(for: field.id)
))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
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)
}
}
.navigationTitle(String(localized: "screen.title.import_plan"))
.navigationBarTitleDisplayMode(.inline)
}
}

View File

@@ -3,11 +3,18 @@ import SwiftUI
import UniformTypeIdentifiers
@MainActor
final class ImportFlowViewModel: ObservableObject {
final class ImportFlowViewModel: ObservableObject {
enum FieldMappingState {
case incomplete
case partiallyValid
case valid
}
@Published var template = MoodwellStateOfMindTemplate.template
@Published var entries: [MoodwellEntry] = []
@Published var sourceKeys: [String] = []
@Published var mapping = MoodwellStateOfMindTemplate.defaultMapping
@Published var fieldFormatters: [String: FieldFormatterConfig] = [:]
@Published var moodValenceScale: [Int: Double] = [1: -0.8, 2: -0.25, 3: 0.3, 4: 0.8]
@Published var dryRunResult: DryRunResult?
@Published var isImporting = false
@@ -19,6 +26,7 @@ import UniformTypeIdentifiers
private let healthKitService = HealthKitService()
init() {
ensureFormatterDefaults()
loadBundledSample()
}
@@ -30,8 +38,8 @@ import UniformTypeIdentifiers
loadFile(from: url)
}
func loadFile(from url: URL) {
do {
func loadFile(from url: URL) {
do {
let data = try Data(contentsOf: url)
let payload = try JSONDecoder().decode(MoodwellPayload.self, from: data)
entries = payload.mymoods
@@ -39,33 +47,39 @@ import UniformTypeIdentifiers
sourceKeys = Array(firstEntry.sourceDictionary.keys).sorted()
} else {
sourceKeys = []
}
dryRunResult = nil
statusMessage = String.localizedStringWithFormat(
NSLocalizedString("status.loaded", comment: ""),
entries.count
)
errorMessage = nil
} catch {
errorMessage = String(localized: "error.load.file") + " \(error.localizedDescription)"
}
ensureFormatterDefaults()
dryRunResult = nil
statusMessage = String.localizedStringWithFormat(
NSLocalizedString("status.loaded", comment: ""),
entries.count
)
errorMessage = nil
} catch {
errorMessage = String(localized: "error.load.file") + " \(error.localizedDescription)"
}
}
func runDryRun() {
let result = MoodwellStateOfMindTemplate.makeDrafts(entries: entries, mapping: mapping, moodValenceScale: moodValenceScale)
dryRunResult = result
if result.errors.isEmpty {
statusMessage = String.localizedStringWithFormat(
NSLocalizedString("status.dryrun.success", comment: ""),
result.drafts.count
)
} else {
statusMessage = String.localizedStringWithFormat(
NSLocalizedString("status.dryrun.withErrors", comment: ""),
result.errors.count
)
}
func runDryRun() {
let result = MoodwellStateOfMindTemplate.makeDrafts(
entries: entries,
mapping: mapping,
moodValenceScale: moodValenceScale,
fieldFormatters: fieldFormatters
)
dryRunResult = result
if result.errors.isEmpty {
statusMessage = String.localizedStringWithFormat(
NSLocalizedString("status.dryrun.success", comment: ""),
result.drafts.count
)
} else {
statusMessage = String.localizedStringWithFormat(
NSLocalizedString("status.dryrun.withErrors", comment: ""),
result.errors.count
)
}
}
func commitImport() async {
guard let dryRunResult else {
@@ -80,16 +94,208 @@ import UniformTypeIdentifiers
isImporting = true
defer { isImporting = false }
do {
try await healthKitService.requestWriteAccessForStateOfMind()
try await healthKitService.save(dryRunResult.drafts)
statusMessage = String.localizedStringWithFormat(
NSLocalizedString("status.import.success", comment: ""),
dryRunResult.drafts.count
)
errorMessage = nil
} catch {
do {
try await healthKitService.requestWriteAccessForStateOfMind()
try await healthKitService.save(dryRunResult.drafts)
statusMessage = String.localizedStringWithFormat(
NSLocalizedString("status.import.success", comment: ""),
dryRunResult.drafts.count
)
errorMessage = nil
} catch {
errorMessage = String(localized: "error.import.failed") + " \(error.localizedDescription)"
}
}
func sourceExamples(for field: TemplateField, limit: Int = 3) -> [String] {
let sourceKey = mapping.sourceKey(for: field.id)
guard !sourceKey.isEmpty else {
return []
}
return entries
.compactMap { $0.sourceDictionary[sourceKey]?.displayString() }
.filter { !$0.isEmpty }
.prefix(limit)
.map { $0 }
}
func formattingPreview(for field: TemplateField) -> FieldFormattingPreview {
let sourceKey = mapping.sourceKey(for: field.id)
guard !sourceKey.isEmpty else {
return FieldFormattingPreview(matched: 0, total: 0, examples: [])
}
let formatter = formatter(for: field.id)
var matched = 0
var total = 0
var examples: [String] = []
for entry in entries {
guard let raw = entry.sourceDictionary[sourceKey], raw != .null else {
continue
}
guard let rawString = raw.sourceString() else {
continue
}
total += 1
if
let transformed = ImportFieldFormatterEngine.apply(sourceText: rawString, config: formatter),
MoodwellStateOfMindTemplate.validateFormattedTarget(
fieldID: field.id,
formatted: transformed,
moodValenceScale: moodValenceScale
)
{
matched += 1
if examples.count < 3 {
examples.append("\(rawString)\(transformed)")
}
}
}
return FieldFormattingPreview(matched: matched, total: total, examples: examples)
}
func formatter(for fieldID: String) -> FieldFormatterConfig {
if let formatter = fieldFormatters[fieldID] {
return formatter
}
guard let field = template.fields.first(where: { $0.id == fieldID }) else {
return .default(for: .string)
}
return .default(for: field.type)
}
func updateFormatter(for fieldID: String, _ update: (inout FieldFormatterConfig) -> Void) {
var config = formatter(for: fieldID)
update(&config)
fieldFormatters[fieldID] = config
}
private func ensureFormatterDefaults() {
for field in template.fields {
if fieldFormatters[field.id] == nil {
fieldFormatters[field.id] = .default(for: field.type)
}
}
}
func mappedSourceKey(for fieldID: String) -> String {
mapping.sourceKey(for: fieldID)
}
func formatterOptions(for field: TemplateField) -> [FieldFormatterType] {
switch field.type {
case .date:
return [
.dateISO8601ToISO8601,
.dateGermanDayMonthYearToISO8601,
.dateGermanDayMonthYearHourMinuteToISO8601,
.dateUnixSecondsToISO8601
]
default:
return [.passthrough]
}
}
func formatterDisplayName(_ type: FieldFormatterType) -> String {
switch type {
case .passthrough:
return String(localized: "formatter.option.passthrough")
case .dateISO8601ToISO8601:
return String(localized: "formatter.option.date_iso")
case .dateGermanDayMonthYearToISO8601:
return String(localized: "formatter.option.date_ddmmyyyy")
case .dateGermanDayMonthYearHourMinuteToISO8601:
return String(localized: "formatter.option.date_ddmmyyyy_hhmm")
case .dateUnixSecondsToISO8601:
return String(localized: "formatter.option.date_unix_seconds")
}
}
func fieldState(for field: TemplateField) -> FieldMappingState {
let sourceKey = mappedSourceKey(for: field.id)
guard !sourceKey.isEmpty else {
return .incomplete
}
let preview = formattingPreview(for: field)
guard preview.total > 0 else {
return .incomplete
}
return preview.matched == preview.total ? .valid : .partiallyValid
}
func fieldStateText(for field: TemplateField) -> String {
switch fieldState(for: field) {
case .incomplete:
return String(localized: "state.incomplete")
case .partiallyValid:
return String(localized: "state.partial")
case .valid:
return String(localized: "state.valid")
}
}
func previewRows(for field: TemplateField, limit: Int = 5) -> [(input: String, output: String, valid: Bool)] {
previewRows(for: field, formatterType: formatter(for: field.id).formatterType, limit: limit)
}
func previewRows(for field: TemplateField, formatterType: FieldFormatterType, limit: Int = 5) -> [(input: String, output: String, valid: Bool)] {
let sourceKey = mappedSourceKey(for: field.id)
guard !sourceKey.isEmpty else {
return []
}
let formatter = FieldFormatterConfig(formatterType: formatterType)
var rows: [(String, String, Bool)] = []
for entry in entries {
guard let raw = entry.sourceDictionary[sourceKey], raw != .null, let input = raw.sourceString() else {
continue
}
let output = ImportFieldFormatterEngine.apply(sourceText: input, config: formatter) ?? ""
let valid = !output.isEmpty && MoodwellStateOfMindTemplate.validateFormattedTarget(
fieldID: field.id,
formatted: output,
moodValenceScale: moodValenceScale
)
rows.append((input, output, valid))
if rows.count >= limit {
break
}
}
return rows
}
func formatterMatchSummary(for field: TemplateField, formatterType: FieldFormatterType, limit: Int = 5) -> (matched: Int, total: Int) {
let rows = previewRows(for: field, formatterType: formatterType, limit: limit)
let matched = rows.filter { $0.valid }.count
return (matched, rows.count)
}
func mappingSummary() -> (validFields: Int, totalFields: Int, validRows: Int, totalRows: Int) {
var validFields = 0
var validRows = 0
var totalRows = 0
for field in template.fields {
let preview = formattingPreview(for: field)
if preview.total > 0 && preview.matched == preview.total {
validFields += 1
}
validRows += preview.matched
totalRows += preview.total
}
return (validFields, template.fields.count, validRows, totalRows)
}
func sourceExample(for sourceKey: String) -> String {
for entry in entries {
if let value = entry.sourceDictionary[sourceKey]?.sourceString(), !value.isEmpty {
return value
}
}
return String(localized: "text.no_source_values")
}
}

View File

@@ -12,6 +12,10 @@ struct TemplateField: Identifiable, Hashable {
let id: String
let title: String
let description: String
let targetExplanation: String
let targetExample: String
let targetFormat: String
let targetAllowedValues: String?
let type: FieldType
let isRequired: Bool
}
@@ -31,6 +35,38 @@ struct ImportMapping: Equatable {
}
}
enum FieldFormatterType: String, CaseIterable, Identifiable {
case passthrough
case dateISO8601ToISO8601
case dateGermanDayMonthYearToISO8601
case dateGermanDayMonthYearHourMinuteToISO8601
case dateUnixSecondsToISO8601
var id: String { rawValue }
}
struct FieldFormatterConfig: Equatable {
var formatterType: FieldFormatterType
static func `default`(for fieldType: TemplateField.FieldType) -> FieldFormatterConfig {
if fieldType == .date {
return FieldFormatterConfig(formatterType: .dateISO8601ToISO8601)
}
return FieldFormatterConfig(formatterType: .passthrough)
}
}
struct FieldFormattingPreview {
let matched: Int
let total: Int
let examples: [String]
var isPartial: Bool {
total > 0 && matched < total
}
}
struct MoodwellPayload: Decodable {
let mymoods: [MoodwellEntry]
}
@@ -57,7 +93,9 @@ struct MoodwellEntry: Decodable, Identifiable {
"arrayOfWeathers": .stringArray(arrayOfWeathers),
"createdAt": .string(createdAt),
"arrayOfActivities": .stringArray(arrayOfActivities),
"arrayOfGoodEmotions": .stringArray(arrayOfGoodEmotions)
"arrayOfGoodEmotions": .stringArray(arrayOfGoodEmotions),
"combinedEmotions": .stringArray(arrayOfGoodEmotions + arrayOfBadEmotions),
"combinedAssociations": .stringArray(arrayOfActivities + arrayOfWeathers.map { "weather:\($0)" })
]
}
}
@@ -66,6 +104,7 @@ enum ImportValue: Hashable {
case string(String)
case number(Double)
case stringArray([String])
case date(Date)
case null
func asString() -> String? {
@@ -88,6 +127,87 @@ enum ImportValue: Hashable {
}
return nil
}
func asDate() -> Date? {
if case let .date(value) = self {
return value
}
return nil
}
func sourceString() -> String? {
switch self {
case let .string(value):
return value
case let .number(value):
if value.rounded() == value {
return String(Int(value))
}
return String(value)
case let .stringArray(value):
return value.joined(separator: ", ")
case let .date(value):
return value.formatted(date: .abbreviated, time: .shortened)
case .null:
return nil
}
}
func displayString() -> String {
sourceString() ?? "<null>"
}
}
enum ImportFieldFormatterEngine {
static func apply(sourceText: String, config: FieldFormatterConfig) -> String? {
switch config.formatterType {
case .passthrough:
return sourceText
case .dateISO8601ToISO8601:
guard let date = parseISO8601(sourceText.trimmingCharacters(in: .whitespacesAndNewlines)) else {
return nil
}
return iso8601String(from: date)
case .dateGermanDayMonthYearToISO8601:
return convertDate(sourceText, sourcePattern: "dd.MM.yyyy")
case .dateGermanDayMonthYearHourMinuteToISO8601:
return convertDate(sourceText, sourcePattern: "dd.MM.yyyy HH:mm")
case .dateUnixSecondsToISO8601:
guard let seconds = Double(sourceText.trimmingCharacters(in: .whitespacesAndNewlines)) else {
return nil
}
return iso8601String(from: Date(timeIntervalSince1970: seconds))
}
}
private static func convertDate(_ input: String, sourcePattern: String) -> String? {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.calendar = Calendar(identifier: .gregorian)
formatter.dateFormat = sourcePattern
guard let date = formatter.date(from: input.trimmingCharacters(in: .whitespacesAndNewlines)) else {
return nil
}
return iso8601String(from: date)
}
private static func iso8601String(from date: Date) -> String {
let output = ISO8601DateFormatter()
output.formatOptions = [.withInternetDateTime]
return output.string(from: date)
}
private static func parseISO8601(_ input: String) -> Date? {
let withFractional = ISO8601DateFormatter()
withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = withFractional.date(from: input) {
return date
}
let plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
return plain.date(from: input)
}
}
struct StateOfMindDraft: Identifiable, Hashable {

View File

@@ -7,26 +7,82 @@ enum MoodwellStateOfMindTemplate {
title: String(localized: "template.title.state_of_mind"),
localizedHealthTypeName: String(localized: "health.segment.state_of_mind"),
fields: [
TemplateField(id: "createdAt", title: String(localized: "field.created_at"), description: String(localized: "field.created_at.description"), type: .date, isRequired: true),
TemplateField(id: "moodType", title: String(localized: "field.mood_type"), description: String(localized: "field.mood_type.description"), type: .number, isRequired: true),
TemplateField(id: "arrayOfGoodEmotions", title: String(localized: "field.good_emotions"), description: String(localized: "field.good_emotions.description"), type: .stringArray, isRequired: false),
TemplateField(id: "arrayOfBadEmotions", title: String(localized: "field.bad_emotions"), description: String(localized: "field.bad_emotions.description"), type: .stringArray, isRequired: false),
TemplateField(id: "arrayOfActivities", title: String(localized: "field.activities"), description: String(localized: "field.activities.description"), type: .stringArray, isRequired: false),
TemplateField(id: "arrayOfWeathers", title: String(localized: "field.weather"), description: String(localized: "field.weather.description"), type: .stringArray, isRequired: false),
TemplateField(id: "notesString", title: String(localized: "field.notes"), description: String(localized: "field.notes.description"), type: .string, isRequired: false),
TemplateField(id: "moodUniqueIdentifier", title: String(localized: "field.external_id"), description: String(localized: "field.external_id.description"), type: .string, isRequired: false)
TemplateField(
id: "date",
title: String(localized: "field.date"),
description: String(localized: "field.date.description"),
targetExplanation: String(localized: "field.date.target_explanation"),
targetExample: String(localized: "field.date.target_example"),
targetFormat: String(localized: "field.date.target_format"),
targetAllowedValues: nil,
type: .date,
isRequired: true
),
TemplateField(
id: "valence",
title: String(localized: "field.valence"),
description: String(localized: "field.valence.description"),
targetExplanation: String(localized: "field.valence.target_explanation"),
targetExample: String(localized: "field.valence.target_example"),
targetFormat: String(localized: "field.valence.target_format"),
targetAllowedValues: String(localized: "field.valence.allowed"),
type: .number,
isRequired: true
),
TemplateField(
id: "labels",
title: String(localized: "field.labels"),
description: String(localized: "field.labels.description"),
targetExplanation: String(localized: "field.labels.target_explanation"),
targetExample: String(localized: "field.labels.target_example"),
targetFormat: String(localized: "field.labels.target_format"),
targetAllowedValues: String(localized: "field.labels.allowed"),
type: .stringArray,
isRequired: false
),
TemplateField(
id: "associations",
title: String(localized: "field.associations"),
description: String(localized: "field.associations.description"),
targetExplanation: String(localized: "field.associations.target_explanation"),
targetExample: String(localized: "field.associations.target_example"),
targetFormat: String(localized: "field.associations.target_format"),
targetAllowedValues: String(localized: "field.associations.allowed"),
type: .stringArray,
isRequired: false
),
TemplateField(
id: "note",
title: String(localized: "field.note"),
description: String(localized: "field.note.description"),
targetExplanation: String(localized: "field.note.target_explanation"),
targetExample: String(localized: "field.note.target_example"),
targetFormat: String(localized: "field.note.target_format"),
targetAllowedValues: nil,
type: .string,
isRequired: false
),
TemplateField(
id: "external_id",
title: String(localized: "field.external_id"),
description: String(localized: "field.external_id.description"),
targetExplanation: String(localized: "field.external_id.target_explanation"),
targetExample: String(localized: "field.external_id.target_example"),
targetFormat: String(localized: "field.external_id.target_format"),
targetAllowedValues: nil,
type: .string,
isRequired: false
)
]
)
static let defaultMapping = ImportMapping(sourceKeyByFieldID: [
"createdAt": "createdAt",
"moodType": "moodType",
"arrayOfGoodEmotions": "arrayOfGoodEmotions",
"arrayOfBadEmotions": "arrayOfBadEmotions",
"arrayOfActivities": "arrayOfActivities",
"arrayOfWeathers": "arrayOfWeathers",
"notesString": "notesString",
"moodUniqueIdentifier": "moodUniqueIdentifier"
"date": "createdAt",
"valence": "moodType",
"labels": "combinedEmotions",
"associations": "combinedAssociations",
"note": "notesString",
"external_id": "moodUniqueIdentifier"
])
static let emotionToLabel: [String: HKStateOfMind.Label] = [
@@ -91,66 +147,54 @@ enum MoodwellStateOfMindTemplate {
static func makeDrafts(
entries: [MoodwellEntry],
mapping: ImportMapping,
moodValenceScale: [Int: Double]
moodValenceScale: [Int: Double],
fieldFormatters: [String: FieldFormatterConfig] = [:]
) -> DryRunResult {
let dateFormatter = ISO8601DateFormatter()
var drafts: [StateOfMindDraft] = []
var warnings: [String] = []
var errors: [String] = []
for entry in entries {
let source = entry.sourceDictionary
guard
let dateKey = mapping.sourceKeyByFieldID["createdAt"],
let dateValue = source[dateKey]?.asString(),
let date = dateFormatter.date(from: dateValue)
let dateRaw = formattedFieldValue("date", source: source, mapping: mapping, fieldFormatters: fieldFormatters),
let date = parseISO8601(dateRaw)
else {
errors.append("Missing or invalid date for record \(entry.moodUniqueIdentifier).")
continue
}
guard
let moodTypeKey = mapping.sourceKeyByFieldID["moodType"],
let moodTypeValue = source[moodTypeKey]?.asNumber()
let valenceRaw = formattedFieldValue("valence", source: source, mapping: mapping, fieldFormatters: fieldFormatters),
let valence = parseValence(from: valenceRaw, moodValenceScale: moodValenceScale)
else {
errors.append("Missing moodType for record \(entry.moodUniqueIdentifier).")
errors.append("Missing or invalid valence for record \(entry.moodUniqueIdentifier).")
continue
}
let moodTypeInt = Int(moodTypeValue)
guard let valence = moodValenceScale[moodTypeInt] else {
errors.append("No valence mapping for moodType \(moodTypeInt) on record \(entry.moodUniqueIdentifier).")
continue
}
let goodKey = mapping.sourceKeyByFieldID["arrayOfGoodEmotions", default: ""]
let badKey = mapping.sourceKeyByFieldID["arrayOfBadEmotions", default: ""]
let emotionInputs = (source[goodKey]?.asStringArray() ?? []) + (source[badKey]?.asStringArray() ?? [])
let emotionInputs = tokenize(formattedFieldValue("labels", source: source, mapping: mapping, fieldFormatters: fieldFormatters))
let mappedLabels = Set(emotionInputs.compactMap { emotion in
MoodwellStateOfMindTemplate.emotionToLabel[emotion.lowercased()]
emotionToLabel[emotion.lowercased()]
})
let unknownEmotions = emotionInputs.filter { MoodwellStateOfMindTemplate.emotionToLabel[$0.lowercased()] == nil }
let unknownEmotions = emotionInputs.filter { emotionToLabel[$0.lowercased()] == nil }
if !unknownEmotions.isEmpty {
warnings.append("Unmapped emotions on \(entry.moodUniqueIdentifier): \(unknownEmotions.joined(separator: ", ")).")
}
let activityKey = mapping.sourceKeyByFieldID["arrayOfActivities", default: ""]
let weatherKey = mapping.sourceKeyByFieldID["arrayOfWeathers", default: ""]
let activityInputs = source[activityKey]?.asStringArray() ?? []
let weatherInputs = source[weatherKey]?.asStringArray() ?? []
let associationInputs = tokenize(formattedFieldValue("associations", source: source, mapping: mapping, fieldFormatters: fieldFormatters))
let activityInputs = associationInputs.filter { !$0.lowercased().hasPrefix("weather:") }
let weatherInputs = associationInputs.filter { $0.lowercased().hasPrefix("weather:") }
var associations = Set(activityInputs.compactMap { activity in
MoodwellStateOfMindTemplate.activityToAssociation[activity.lowercased()]
activityToAssociation[activity.lowercased()]
})
if !weatherInputs.isEmpty {
associations.insert(.weather)
}
let externalIDKey = mapping.sourceKeyByFieldID["moodUniqueIdentifier", default: ""]
let notesKey = mapping.sourceKeyByFieldID["notesString", default: ""]
let externalID = source[externalIDKey]?.asString() ?? entry.moodUniqueIdentifier
let notes = source[notesKey]?.asString() ?? ""
let externalID = formattedFieldValue("external_id", source: source, mapping: mapping, fieldFormatters: fieldFormatters) ?? entry.moodUniqueIdentifier
let notes = formattedFieldValue("note", source: source, mapping: mapping, fieldFormatters: fieldFormatters) ?? ""
var metadata: [String: String] = [
"BulkHealth.Source": "Moodwell",
@@ -180,4 +224,82 @@ enum MoodwellStateOfMindTemplate {
return DryRunResult(drafts: drafts.sorted { $0.date < $1.date }, warnings: warnings, errors: errors)
}
static func validateFormattedTarget(
fieldID: String,
formatted: String,
moodValenceScale: [Int: Double]
) -> Bool {
switch fieldID {
case "date":
return parseISO8601(formatted) != nil
case "valence":
return parseValence(from: formatted, moodValenceScale: moodValenceScale) != nil
case "labels", "associations":
return !tokenize(formatted).isEmpty
case "note", "external_id":
return !formatted.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
default:
return false
}
}
private static func formattedFieldValue(
_ fieldID: String,
source: [String: ImportValue],
mapping: ImportMapping,
fieldFormatters: [String: FieldFormatterConfig]
) -> String? {
guard
let field = template.fields.first(where: { $0.id == fieldID }),
let sourceKey = mapping.sourceKeyByFieldID[fieldID],
let sourceText = source[sourceKey]?.sourceString()
else {
return nil
}
let formatter = fieldFormatters[fieldID] ?? .default(for: field.type)
return ImportFieldFormatterEngine.apply(sourceText: sourceText, config: formatter)
}
private static func parseISO8601(_ string: String) -> Date? {
let withFractional = ISO8601DateFormatter()
withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = withFractional.date(from: string) {
return date
}
let plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
return plain.date(from: string)
}
private static func parseValence(from string: String, moodValenceScale: [Int: Double]) -> Double? {
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return nil
}
if let number = Double(trimmed), (-1.0...1.0).contains(number) {
return number
}
if
let moodType = Int(trimmed),
let mapped = moodValenceScale[moodType]
{
return mapped
}
return nil
}
private static func tokenize(_ value: String?) -> [String] {
guard let value else {
return []
}
return value
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
}

View File

@@ -51,3 +51,77 @@
"text.mapping.unavailable" = "Lade zuerst eine JSON-Datei, bevor du die Feldzuordnung bearbeitest.";
"error.healthkit.unsupported" = "Der Gemütszustand-Import erfordert iOS 18 oder neuer.";
"section.formatting" = "Feldformatierung";
"label.source_examples" = "Quellbeispiele";
"label.formatter" = "Formatierer";
"formatter.option.passthrough" = "Unverändert";
"formatter.option.date_pattern" = "Datumsformat";
"placeholder.date_pattern" = "Quelldatumsformat (oder ISO8601)";
"placeholder.regex_pattern" = "Regex-Muster (optional)";
"placeholder.regex_replacement" = "Regex-Ersetzung";
"label.formatter_coverage" = "Gemappt: %lld/%lld Werte";
"label.formatter_matches" = "Treffer: %lld/%lld";
"button.configure_fields" = "Felder konfigurieren";
"button.done" = "Fertig";
"screen.title.field_mapping" = "Gemütszustand-Felder";
"label.mapped_source" = "Quelle: %@";
"label.select_source_field" = "Quellfeld";
"text.field_unmapped" = "Kein Quellfeld ausgewählt";
"text.no_source_values" = "Keine Quellwerte verfügbar";
"section.target" = "Apple-Health-Ziel";
"field.date" = "Datum";
"field.date.description" = "Zeitpunkt des Gemütszustands-Eintrags.";
"field.date.target_format" = "Zielformat: ISO8601 (yyyy-MM-dd'T'HH:mm:ssZ).";
"field.valence" = "Valenz";
"field.valence.description" = "Gesamte Angenehmheit für Apple Health.";
"field.valence.target_format" = "Zielformat: Zahl";
"field.valence.allowed" = "Erlaubte Werte: -1,0 bis 1,0";
"field.labels" = "Labels";
"field.labels.description" = "Emotions-Labels des Eintrags.";
"field.labels.target_format" = "Zielformat: Array aus Health-Labels";
"field.labels.allowed" = "Erlaubte Werte: Health-Labels (z. B. Happy, Sad, Calm, Stressed).";
"field.associations" = "Zuordnungen";
"field.associations.description" = "Lebensbereiche des Eintrags.";
"field.associations.target_format" = "Zielformat: Array aus Health-Zuordnungen";
"field.associations.allowed" = "Erlaubte Werte: Work, Friends, Family, Fitness, Weather usw.";
"field.note" = "Notiz";
"field.note.description" = "Optionale Notiz als Metadaten.";
"field.note.target_format" = "Zielformat: String-Metadaten";
"field.external_id.target_format" = "Zielformat: String-Metadaten";
"text.field_detail_intro" = "Du mapst hier Quelldaten auf das Apple-Health-Zielfeld.";
"label.example_value" = "Beispiel: %@";
"field.date.target_explanation" = "Das Formatziel ist ein String nach ISO8601-Standard.";
"field.date.target_example" = "2024-03-31T21:15:00Z";
"field.valence.target_explanation" = "Valenz ist ein numerischer Stimmungswert zwischen -1 und +1.";
"field.valence.target_example" = "0,35";
"field.labels.target_explanation" = "Labels sind einzelne Apple-Health-Emotionswörter.";
"field.labels.target_example" = "Happy, Calm";
"field.associations.target_explanation" = "Associations sind Apple-Health-Lebensbereiche, die den Eintrag kontextualisieren.";
"field.associations.target_example" = "Work, Friends, Weather";
"field.note.target_explanation" = "Notiz ist optionaler Freitext und wird als Metadatum gespeichert.";
"field.note.target_example" = "Stressiger Tag, aber produktiv.";
"field.external_id.target_explanation" = "Externe ID ist eine optionale Quellreferenz zur Nachverfolgung.";
"field.external_id.target_example" = "3D40D694-C406-4B55-8547-D966F132F421";
"button.select_source" = "Quelle wählen";
"button.select_formatter" = "Formatierer wählen";
"button.open_import_plan" = "Import-Plan öffnen";
"screen.title.source" = "Quellauswahl";
"screen.title.formatter" = "Formatiererauswahl";
"screen.title.import_plan" = "Import-Plan";
"section.progress" = "Feldfortschritt";
"section.test_area" = "Testbereich";
"section.plan_summary" = "Planübersicht";
"label.source" = "Quelle";
"label.fields_ready" = "Felder bereit: %lld/%lld";
"label.rows_ready" = "Gültige Zeilen: %lld/%lld";
"label.source_for_field" = "Quelle: %@";
"state.incomplete" = "Unvollständig";
"state.partial" = "Teilweise";
"state.valid" = "Gültig";
"text.formatter_empty" = "Keine Ausgabe";
"field.date.helper" = "Hinweis: Nach erfolgreicher ISO8601-Validierung konvertiert die App den Wert in ein Apple-Health-Datum.";
"text.select" = "auswählen";
"formatter.option.date_iso" = "ISO8601 → ISO8601";
"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";

View File

@@ -51,3 +51,77 @@
"text.mapping.unavailable" = "Load a JSON file before editing field mapping.";
"error.healthkit.unsupported" = "State of Mind import requires iOS 18 or newer.";
"section.formatting" = "Field Formatting";
"label.source_examples" = "Source examples";
"label.formatter" = "Formatter";
"formatter.option.passthrough" = "Passthrough";
"formatter.option.date_pattern" = "Date pattern";
"placeholder.date_pattern" = "Source date pattern (or ISO8601)";
"placeholder.regex_pattern" = "Regex pattern (optional)";
"placeholder.regex_replacement" = "Regex replacement";
"label.formatter_coverage" = "Mapped: %lld/%lld values";
"label.formatter_matches" = "Matches: %lld/%lld";
"button.configure_fields" = "Configure Fields";
"button.done" = "Done";
"screen.title.field_mapping" = "State of Mind Fields";
"label.mapped_source" = "Source: %@";
"label.select_source_field" = "Source Field";
"text.field_unmapped" = "No source field selected";
"text.no_source_values" = "No source values available";
"section.target" = "Apple Health Target";
"field.date" = "Date";
"field.date.description" = "When the state of mind was logged.";
"field.date.target_format" = "Target format: ISO8601 (yyyy-MM-dd'T'HH:mm:ssZ).";
"field.valence" = "Valence";
"field.valence.description" = "Overall pleasantness score used by Apple Health.";
"field.valence.target_format" = "Target format: Number";
"field.valence.allowed" = "Allowed values: -1.0 to 1.0";
"field.labels" = "Labels";
"field.labels.description" = "Emotion labels attached to the log entry.";
"field.labels.target_format" = "Target format: Array of Health labels";
"field.labels.allowed" = "Allowed values: Health labels (e.g. Happy, Sad, Calm, Stressed).";
"field.associations" = "Associations";
"field.associations.description" = "Life domains associated with the entry.";
"field.associations.target_format" = "Target format: Array of Health associations";
"field.associations.allowed" = "Allowed values: Work, Friends, Family, Fitness, Weather, etc.";
"field.note" = "Note";
"field.note.description" = "Optional note written as metadata.";
"field.note.target_format" = "Target format: String metadata";
"field.external_id.target_format" = "Target format: String metadata";
"text.field_detail_intro" = "You are mapping source data to this Apple Health target field.";
"label.example_value" = "Example: %@";
"field.date.target_explanation" = "The target format is an ISO8601 string.";
"field.date.target_example" = "2024-03-31T21:15:00Z";
"field.valence.target_explanation" = "Valence is a numeric mood score between -1 and +1.";
"field.valence.target_example" = "0.35";
"field.labels.target_explanation" = "Labels are individual Apple Health emotion words.";
"field.labels.target_example" = "Happy, Calm";
"field.associations.target_explanation" = "Associations are Apple Health life domains used as context.";
"field.associations.target_example" = "Work, Friends, Weather";
"field.note.target_explanation" = "Note is optional free text stored as metadata.";
"field.note.target_example" = "Stressful day, but productive.";
"field.external_id.target_explanation" = "External ID is an optional source reference for traceability.";
"field.external_id.target_example" = "3D40D694-C406-4B55-8547-D966F132F421";
"button.select_source" = "Select Source";
"button.select_formatter" = "Select Formatter";
"button.open_import_plan" = "Open Import Plan";
"screen.title.source" = "Source Selection";
"screen.title.formatter" = "Formatter Selection";
"screen.title.import_plan" = "Import Plan";
"section.progress" = "Field Progress";
"section.test_area" = "Test Area";
"section.plan_summary" = "Plan Summary";
"label.source" = "Source";
"label.fields_ready" = "Fields ready: %lld/%lld";
"label.rows_ready" = "Rows valid: %lld/%lld";
"label.source_for_field" = "Source: %@";
"state.incomplete" = "Incomplete";
"state.partial" = "Partial";
"state.valid" = "Valid";
"text.formatter_empty" = "No output";
"field.date.helper" = "Helper: after ISO8601 validation, the app converts this value to an Apple Health Date.";
"text.select" = "select";
"formatter.option.date_iso" = "ISO8601 → ISO8601";
"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";

View File

@@ -51,3 +51,77 @@
"text.mapping.unavailable" = "Carga primero un archivo JSON antes de editar la asignación de campos.";
"error.healthkit.unsupported" = "La importación de estado de ánimo requiere iOS 18 o posterior.";
"section.formatting" = "Formato de campos";
"label.source_examples" = "Ejemplos de origen";
"label.formatter" = "Formateador";
"formatter.option.passthrough" = "Sin cambios";
"formatter.option.date_pattern" = "Patrón de fecha";
"placeholder.date_pattern" = "Patrón de fecha de origen (o ISO8601)";
"placeholder.regex_pattern" = "Patrón regex (opcional)";
"placeholder.regex_replacement" = "Reemplazo regex";
"label.formatter_coverage" = "Mapeado: %lld/%lld valores";
"label.formatter_matches" = "Coincidencias: %lld/%lld";
"button.configure_fields" = "Configurar campos";
"button.done" = "Listo";
"screen.title.field_mapping" = "Campos de estado de ánimo";
"label.mapped_source" = "Origen: %@";
"label.select_source_field" = "Campo de origen";
"text.field_unmapped" = "No hay campo de origen seleccionado";
"text.no_source_values" = "No hay valores de origen disponibles";
"section.target" = "Destino de Apple Health";
"field.date" = "Fecha";
"field.date.description" = "Momento en que se registró el estado de ánimo.";
"field.date.target_format" = "Formato objetivo: ISO8601 (yyyy-MM-dd'T'HH:mm:ssZ).";
"field.valence" = "Valencia";
"field.valence.description" = "Puntuación general de agrado para Apple Health.";
"field.valence.target_format" = "Formato de destino: Número";
"field.valence.allowed" = "Valores permitidos: -1.0 a 1.0";
"field.labels" = "Etiquetas";
"field.labels.description" = "Etiquetas emocionales del registro.";
"field.labels.target_format" = "Formato de destino: Array de etiquetas de Health";
"field.labels.allowed" = "Valores permitidos: etiquetas de Health (p. ej. Happy, Sad, Calm, Stressed).";
"field.associations" = "Asociaciones";
"field.associations.description" = "Ámbitos de vida asociados al registro.";
"field.associations.target_format" = "Formato de destino: Array de asociaciones de Health";
"field.associations.allowed" = "Valores permitidos: Work, Friends, Family, Fitness, Weather, etc.";
"field.note" = "Nota";
"field.note.description" = "Nota opcional guardada como metadatos.";
"field.note.target_format" = "Formato de destino: Metadatos de texto";
"field.external_id.target_format" = "Formato de destino: Metadatos de texto";
"text.field_detail_intro" = "Aquí asignas datos de origen al campo de destino de Apple Health.";
"label.example_value" = "Ejemplo: %@";
"field.date.target_explanation" = "El formato objetivo es una cadena ISO8601.";
"field.date.target_example" = "2024-03-31T21:15:00Z";
"field.valence.target_explanation" = "La valencia es una puntuación numérica entre -1 y +1.";
"field.valence.target_example" = "0.35";
"field.labels.target_explanation" = "Las etiquetas son palabras de emoción de Apple Health.";
"field.labels.target_example" = "Happy, Calm";
"field.associations.target_explanation" = "Las asociaciones son ámbitos de vida de Apple Health.";
"field.associations.target_example" = "Work, Friends, Weather";
"field.note.target_explanation" = "La nota es texto libre opcional guardado como metadatos.";
"field.note.target_example" = "Día estresante, pero productivo.";
"field.external_id.target_explanation" = "El ID externo es una referencia opcional de origen para trazabilidad.";
"field.external_id.target_example" = "3D40D694-C406-4B55-8547-D966F132F421";
"button.select_source" = "Seleccionar origen";
"button.select_formatter" = "Seleccionar formateador";
"button.open_import_plan" = "Abrir plan de importación";
"screen.title.source" = "Selección de origen";
"screen.title.formatter" = "Selección de formateador";
"screen.title.import_plan" = "Plan de importación";
"section.progress" = "Progreso del campo";
"section.test_area" = "Área de prueba";
"section.plan_summary" = "Resumen del plan";
"label.source" = "Origen";
"label.fields_ready" = "Campos listos: %lld/%lld";
"label.rows_ready" = "Filas válidas: %lld/%lld";
"label.source_for_field" = "Origen: %@";
"state.incomplete" = "Incompleto";
"state.partial" = "Parcial";
"state.valid" = "Válido";
"text.formatter_empty" = "Sin salida";
"field.date.helper" = "Ayuda: tras validar ISO8601, la app convierte este valor en fecha de Apple Health.";
"text.select" = "seleccionar";
"formatter.option.date_iso" = "ISO8601 → ISO8601";
"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";

View File

@@ -51,3 +51,77 @@
"text.mapping.unavailable" = "Chargez d'abord un fichier JSON avant de modifier la correspondance des champs.";
"error.healthkit.unsupported" = "L'import d'état d'esprit nécessite iOS 18 ou version ultérieure.";
"section.formatting" = "Formatage des champs";
"label.source_examples" = "Exemples source";
"label.formatter" = "Formateur";
"formatter.option.passthrough" = "Sans modification";
"formatter.option.date_pattern" = "Format de date";
"placeholder.date_pattern" = "Format de date source (ou ISO8601)";
"placeholder.regex_pattern" = "Motif regex (optionnel)";
"placeholder.regex_replacement" = "Remplacement regex";
"label.formatter_coverage" = "Mappé : %lld/%lld valeurs";
"label.formatter_matches" = "Correspondances : %lld/%lld";
"button.configure_fields" = "Configurer les champs";
"button.done" = "Terminé";
"screen.title.field_mapping" = "Champs d'état d'esprit";
"label.mapped_source" = "Source : %@";
"label.select_source_field" = "Champ source";
"text.field_unmapped" = "Aucun champ source sélectionné";
"text.no_source_values" = "Aucune valeur source disponible";
"section.target" = "Cible Apple Health";
"field.date" = "Date";
"field.date.description" = "Moment de saisie de l'état d'esprit.";
"field.date.target_format" = "Format cible : ISO8601 (yyyy-MM-dd'T'HH:mm:ssZ).";
"field.valence" = "Valence";
"field.valence.description" = "Score global d'agréabilité pour Apple Health.";
"field.valence.target_format" = "Format cible : Nombre";
"field.valence.allowed" = "Valeurs autorisées : -1,0 à 1,0";
"field.labels" = "Étiquettes";
"field.labels.description" = "Étiquettes émotionnelles de l'entrée.";
"field.labels.target_format" = "Format cible : Tableau d'étiquettes Health";
"field.labels.allowed" = "Valeurs autorisées : étiquettes Health (ex. Happy, Sad, Calm, Stressed).";
"field.associations" = "Associations";
"field.associations.description" = "Domaines de vie liés à l'entrée.";
"field.associations.target_format" = "Format cible : Tableau d'associations Health";
"field.associations.allowed" = "Valeurs autorisées : Work, Friends, Family, Fitness, Weather, etc.";
"field.note" = "Note";
"field.note.description" = "Note facultative enregistrée en métadonnées.";
"field.note.target_format" = "Format cible : Métadonnées texte";
"field.external_id.target_format" = "Format cible : Métadonnées texte";
"text.field_detail_intro" = "Vous mappez ici les données source vers ce champ cible Apple Health.";
"label.example_value" = "Exemple : %@";
"field.date.target_explanation" = "Le format cible est une chaîne ISO8601.";
"field.date.target_example" = "2024-03-31T21:15:00Z";
"field.valence.target_explanation" = "La valence est un score numérique entre -1 et +1.";
"field.valence.target_example" = "0,35";
"field.labels.target_explanation" = "Les étiquettes sont des mots d'émotion Apple Health.";
"field.labels.target_example" = "Happy, Calm";
"field.associations.target_explanation" = "Les associations sont des domaines de vie Apple Health.";
"field.associations.target_example" = "Work, Friends, Weather";
"field.note.target_explanation" = "La note est un texte libre facultatif stocké en métadonnées.";
"field.note.target_example" = "Journée stressante mais productive.";
"field.external_id.target_explanation" = "L'ID externe est une référence source facultative pour la traçabilité.";
"field.external_id.target_example" = "3D40D694-C406-4B55-8547-D966F132F421";
"button.select_source" = "Sélectionner la source";
"button.select_formatter" = "Sélectionner le formateur";
"button.open_import_plan" = "Ouvrir le plan d'import";
"screen.title.source" = "Sélection de la source";
"screen.title.formatter" = "Sélection du formateur";
"screen.title.import_plan" = "Plan d'import";
"section.progress" = "Progression du champ";
"section.test_area" = "Zone de test";
"section.plan_summary" = "Résumé du plan";
"label.source" = "Source";
"label.fields_ready" = "Champs prêts : %lld/%lld";
"label.rows_ready" = "Lignes valides : %lld/%lld";
"label.source_for_field" = "Source : %@";
"state.incomplete" = "Incomplet";
"state.partial" = "Partiel";
"state.valid" = "Valide";
"text.formatter_empty" = "Aucune sortie";
"field.date.helper" = "Aide : après validation ISO8601, l'app convertit cette valeur en date Apple Health.";
"text.select" = "sélectionner";
"formatter.option.date_iso" = "ISO8601 → ISO8601";
"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";

View File

@@ -55,4 +55,15 @@ struct BulkHealthTests {
#expect(result.drafts.isEmpty)
#expect(!result.errors.isEmpty)
}
@Test
func iso8601FormatterSupportsFractionalSeconds() {
let output = ImportFieldFormatterEngine.apply(
sourceText: "2020-06-03T19:52:27.336Z",
config: FieldFormatterConfig(formatterType: .dateISO8601ToISO8601)
)
#expect(output != nil)
#expect(output == "2020-06-03T19:52:27Z")
}
}

View File

@@ -4,3 +4,7 @@
- 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`.