Skip to content

Data Import — Bring Your History From Another App

A one-time CSV import for a user switching to Objectuve: two files — goals.csv and habit_completions.csv — become real goals and check-ins, so switching doesn't mean starting from zero.

Availability

Import is merged to master behind the data_import_enabled feature flag, currently at 0% rollout — it is built, but not reachable by any real user yet. This page documents what ships once the flag ramps, not something you can use today.

Native file-picker support on the iOS/Android WebView is unverified (OBJ-3874) and is a gate on moving the flag past 0%.

How it works

Reached from Settings → Privacy, above the "Export my data" card, at /settings/import. Three steps:

  1. Pick your files — select goals.csv and/or habit_completions.csv. Either file alone is accepted; you don't need both.
  2. Check what we found — a preview shows how many goals and check-ins were parsed, any row-level errors or warnings, and flags a goal whose name collides with one you already have. For each collision you choose skip (default — keep your existing goal, don't import the duplicate) or Add anyway (import it as a separate goal).
  3. Bring it in — committing runs asynchronously; the page polls for progress every 2 seconds while the import is in flight, then shows a summary of what was created, skipped, and failed.

The CSV contract

Column names, requiredness, and validation are the single responsibility of ImportExport::CsvSchema (rails_api/app/services/import_export/csv_schema.rb) — this table matches it exactly.

goals.csv

ColumnRequired?Notes
external_idRequiredYour own identifier for the goal — used to link its rows in habit_completions.csv via goal_external_id. Never stored or shown; it only exists to join the two files during import.
nameRequired
descriptionOptional
life_areaOptionalAn unrecognized value is a warning, not an error — the goal still imports, with life_area falling back to other.
recurrence_typeOptionalMust be one of daily, weekly, custom_days, interval — an unrecognized value is an error and the row is rejected.
recurrence_daysOptionalA single |-delimited string, e.g. mon|wed|fri — not a raw array dump.
recurrence_intervalOptional
duration_minutesOptional
target_amountOptional
unitOptional
target_dateOptional

habit_completions.csv

ColumnRequired?Notes
goal_external_idRequiredMust match an external_id in goals.csv.
completed_dateRequiredISO-8601 YYYY-MM-DD. Rejected as an error if it isn't valid ISO-8601, or if it's in the future.
noteOptional

What doesn't come across, on purpose

  • No streak column, and there isn't one to fill in. Every goal you import starts its streak from zero and rebuilds it from the habit_completions you bring in — the same history, recounted the way Objectuve counts it, not a number copied from another app's rules. This is deliberate, not a gap: a streak is derived, never imported.
  • No visibility column. Every imported goal is created private. A CSV has no business minting a visibility state on your behalf — if you want a goal public or allies-only, that's a change you make afterward, the same as any other goal.

Caps and limits

  • 2 MB per file, 5,000 rows per file. A file over either limit is rejected outright, before any rows are parsed.
  • 5 import attempts per minute, per IP address. Starting an import (beginDataImport) beyond that is rate-limited; the picker distinguishes this from a bad file and tells you how long to wait.

Partial failure

A row that fails during commit — a goal that hits an unexpected error, a duplicate completion, a completion whose goal never got created — is recorded and counted, but it does not roll back the rest of the import. The import still finishes in a committed state with whatever succeeded intact; the summary screen breaks down what was created, skipped, and failed. A failed status is reserved for a catastrophic, unexpected failure of the whole run, not for individual bad rows.

Export → import round-trip

Because goals.csv and habit_completions.csv in your data export use this exact same column contract, exporting your data from Objectuve and importing it straight back in is a supported path — it's also how the importer is integration-tested. This is useful for moving your history between two Objectuve accounts, or just confirming what a full round-trip preserves.

Switching from another app?

If you're coming from Habitica, Strides, Apple Health, or Google Fit, see Import Provider Evaluation for what each provider can and can't hand you in this format today. If you want an ongoing, forward-only sync instead of a one-time historical migration, see Connected Apps — the two solve different problems and aren't interchangeable.

Technical details

  • CSV contract: ImportExport::CsvSchema (rails_api/app/services/import_export/csv_schema.rb) — the only place these column names are declared. Gdpr::ExportBuilder reads it to shape export output; the importer reads it to validate incoming rows.
  • Parse/preview: ImportExport::ParseCsv (rails_api/app/interactions/import_export/parse_csv.rb) — enforces the 2 MB / 5,000-row caps, tracks physical CSV line numbers for error messages, and flags name collisions against your existing goals. Creates a staged DataImport record; nothing is written to Goal/HabitCompletion yet.
  • Commit: ImportExport::Commit (rails_api/app/interactions/import_export/commit.rb) flips the DataImport to committing and enqueues ImportExport::CommitJob (rails_api/app/jobs/import_export/commit_job.rb), which creates goals one at a time through GoalTracking::AddGoal (each goal has real side effects — starter freeze, milestone handling — that can't be bulked), bulk-inserts habit completions in one insert_all, then recomputes streaks from the imported completions.
  • GraphQL surface: beginDataImport (parses and stages), commitDataImport (commits a staged import, with skipGoalExternalIds for collisions to skip), and the dataImportStatus query (polled during commit). Backend Ruby namespace is ImportExport::*; the GraphQL layer keeps the DataImport name — mutations/data_import/{begin_import,commit_import}.rb, resolvers/data_import_queries.rb, types/data_import_type.rb.
  • Frontend: ionic_frontend/src/views/DataImportPage.vue (/settings/import, route-guarded by data_import_enabled) drives a picker → preview → progress → summary state machine via DataImportPicker/DataImportPreview/DataImportProgress/DataImportSummary (ionic_frontend/src/components/dataImport/). Progress is a bounded 2-second poll while committing, not a subscription.
  • Rate limiting: data-import-ip throttle in rails_api/config/initializers/rack_attack.rb — 5 beginDataImport calls per minute per IP, returned as an HTTP 429 with a retry_after the picker surfaces directly.

See Also

Last updated: 2026-09-15 · Version: v4.64 (Phases 1, 3, 5, merged to master)

Loading…