Skip to main content

Storage Schema

getbased stores normal app data in the browser. localStorage holds app data and preferences, and IndexedDB holds auto-backup snapshots plus larger per-device datasets. Opt-in features may use hosted encrypted storage outside this browser; profile sharing is documented separately below because the hosted record contains only ciphertext and share metadata.

localStorage key reference

Keys are namespaced by profile ID where data is per-profile. {profileId} defaults to "default" for the first profile.

Global keys (not profile-specific)

KeyTypePurpose
labcharts-profilesJSON arrayProfile index: [{ id, name, createdAt }]
labcharts-ai-providerstringActive AI provider: 'openrouter' | 'routstr' | 'ppq' | 'venice' | 'ollama'
labcharts-openrouter-keystringOpenRouter API key
labcharts-routstr-keystringRoutstr node session key
labcharts-routstr-nodestringSelected Routstr node URL (from Nostr discovery)
labcharts-cashu-wallet-mintstringCashu wallet mint URL (mirror of IDB meta for sync)
labcharts-cashu-wallet-mnemonicstringBIP-39 wallet seed phrase (encrypted via encryptedSetItem)
labcharts-ppq-keystringPPQ API key
labcharts-venice-keystringVenice AI API key
labcharts-ollamaJSONLocal AI config: { url, model, mode, apiKey }. Key kept as ollama for backwards compat
labcharts-ollama-pii-modelstringLocal AI model used for PII obfuscation (can differ from main chat model)
labcharts-openrouter-modelstringSelected OpenRouter model ID (e.g., anthropic/claude-sonnet-4-6)
labcharts-routstr-modelstringSelected Routstr model ID
labcharts-ppq-modelstringSelected PPQ model ID
labcharts-venice-modelstringSelected Venice model ID
labcharts-openrouter-modelsJSONCached model list from OpenRouter
labcharts-openrouter-pricingJSONCached per-token pricing from OpenRouter
labcharts-routstr-modelsJSONCached model list from Routstr
labcharts-ppq-modelsJSONCached model list from PPQ
labcharts-venice-modelsJSONCached model list from Venice
labcharts-marker-descJSONCached custom marker descriptions from AI
labcharts-time-formatstring'24h' | '12h' — time display preference
labcharts-debugstringDebug mode flag — enables console output and diff viewer
labcharts-pii-choicesessionStorageOne-time PII warning flag (sessionStorage, not localStorage)
getbased-profile-shares-v1JSON arrayActive profile share links created on this browser: share id, profile id/name, share URL, management token, createdAt, expiresAt. Does not include the share password or plaintext profile data

Per-profile keys

KeyTypePurpose
labcharts-{profileId}-importedJSON (may be encrypted)Main data store — see importedData structure below
labcharts-{profileId}-unitsstring'EU' | 'US' unit preference
labcharts-{profileId}-rangeModestring'optimal' | 'reference' chart range mode
labcharts-{profileId}-noteOverlaystring'off' | 'on' — note dots on charts
labcharts-{profileId}-suppOverlaystring'off' | 'on' — supplement bars on charts
labcharts-{profileId}-phaseOverlaystring'off' | 'on' — cycle phase bands on charts
labcharts-{profileId}-chatPersonalitystring'default' | 'house' | 'custom'
labcharts-{profileId}-chatPersonalityCustomJSONCustom personality: { name, icon, promptText, evidenceBased }
labcharts-{profileId}-chatRailOpenstring'true' | 'false' — chat thread rail visibility
labcharts-{profileId}-chatJSON (encrypted)Legacy chat history (migrated to threads on first load)
labcharts-{profileId}-chat-threadsJSONThread index: [{ id, name, createdAt, personalityName, personalityIcon }]
labcharts-{profileId}-chat-t_{id}JSON (encrypted)Per-thread messages: [{ role, content, timestamp }]
labcharts-{profileId}-contextHealthJSONCached AI health dots: { dots, summaries, fingerprints }
labcharts-{profileId}-focusCardJSONCached AI focus card: { fingerprint, text }
labcharts-{profileId}-tourstring'completed' — app tour completion flag
labcharts-{profileId}-cycleTourstring'completed' — cycle tour completion flag
labcharts-{profileId}-onboardedstring'profile-set' | 'dismissed' — onboarding state
labcharts-{profileId}-sexstring'male' | 'female' — profile sex
labcharts-{profileId}-dobstring'YYYY-MM-DD' — date of birth
labcharts-{profileId}-countrystringISO country code
labcharts-{profileId}-zipstringZIP/postal code
labcharts-encryption-enabledstring'true' — encryption at rest enabled
labcharts-{profileId}-enc-saltstringBase64 PBKDF2 salt for this profile’s encryption

importedData structure

Stored as JSON at labcharts-{profileId}-imported. This is everything a user can export/import.
{
  // Lab results — array of dated snapshots
  entries: [
    {
      date: "2025-03-15",            // ISO date string
      markers: {
        "biochemistry.glucose": 5.2, // category.markerKey → numeric value
        "hormones.testosterone": 18.4,
        // ... all measured markers for this date
      }
    }
    // ... more entries
  ],

  // Per-file AI import history. entries[] holds the current live marker values;
  // importSnapshots[] preserves which source file created/reviewed them.
  importSnapshots: [
    {
      id: "snap_1782290000000_abcd",
      date: "2025-03-15",
      fileName: "lab-report.pdf",
      testType: "blood",
      importedAt: 1782290000000,
      importHash: "sha256-or-parser-hash",
      costInfo: { provider: "openrouter", modelId: "..." },
      markers: [
        { rawName: "Glucose", mappedKey: "biochemistry.glucose", value: 5.2, unit: "mmol/L" }
      ],
      excludedIndices: []
    }
  ],

  // entries[].markerSources links current live marker values back to snapshots.
  // Snapshot delete/re-review must mutate through data-merge/lab-entry helpers
  // so marker tombstones and importSnapshots tombstones sync correctly.

  // Standalone notes (independent of lab dates)
  notes: [
    { date: "2025-03-10", text: "Started vitamin D protocol" }
  ],

  // Supplement timeline
  supplements: [
    {
      name: "Magnesium",
      dosage: "morning + evening",   // free-text note (display only)
      type: "supplement",             // or "medication"
      startDate: "2025-01-01",
      endDate: null,                  // null = ongoing
      periods: [                      // optional; for cycling. If absent, uses startDate/endDate
        { start: "2025-01-01", end: null }
      ],
      timesPerDay: 2,                 // optional outer default multiplier — applied to each ingredient unless overridden
      ingredients: [                  // optional; per-serving amounts
        { name: "Bisglycinate", amount: "890mg", timesPerDay: 1 },  // row override: takes precedence over outer
        { name: "Taurate", amount: "890mg", timesPerDay: 2 }        // "" (inherits outer timesPerDay when blank)
      ],
      note: ""
    }
  ],

  // Context cards — all nullable (null = not filled by user)
  diagnoses: {                          // Medical History card
    conditions: [
      { name: "Hashimoto's", severity: "major", since: "2020" }
    ],
    familyHistory: [                    // first-degree + grandparents
      // relative ∈ {mother, father, sibling, child,
      //             maternal_grandmother, maternal_grandfather,
      //             paternal_grandmother, paternal_grandfather}
      { relative: "father", condition: "Heart Attack (MI)", onsetAge: 52, note: "survived" },
      { relative: "mother", condition: "Type 2 Diabetes", onsetAge: 45 }
    ],
    note: ""
  },

  diet: {                               // Diet card
    type: "omnivore",
    restrictions: ["gluten-free", "seed-oil-free"],
    pattern: "intermittent_fasting",
    breakfast: "eggs and avocado",
    breakfastTime: "09:00",             // 24h format
    lunch: "salad with protein",
    lunchTime: "13:00",
    dinner: "meat and vegetables",
    dinnerTime: "18:00",
    snacks: "",
    snacksTime: "",
    note: ""
  },

  exercise: {                           // Exercise card
    frequency: "4-5x_week",
    types: ["strength", "walking"],
    intensity: "moderate",
    dailyMovement: "active",
    note: ""
  },

  sleepRest: {                          // Sleep & Rest card
    duration: "7-8h",
    quality: "good",
    schedule: "consistent",
    roomTemp: "cool",                   // circadian-informed
    issues: ["occasional_waking"],
    environment: ["blackout_curtains", "emf_off"],
    practices: ["mouth_taping", "magnesium"],
    note: ""
  },

  lightCircadian: {                     // Light & Circadian card
    amLight: "sunrise_sunlight",        // circadian-informed
    daytime: "outdoor_work",
    uvExposure: "daily",
    evening: ["blue_light_glasses", "dim_lights"],
    screenTime: "low",
    techEnv: ["wifi_off_night"],
    cold: "cold_shower",
    grounding: "daily_earthing",
    mealTiming: ["time_restricted"],
    note: ""
  },

  stress: {                             // Stress card
    level: "moderate",
    sources: ["work", "finances"],
    management: ["meditation", "exercise"],
    note: ""
  },

  loveLife: {                           // Love Life & Relationships card
    status: "partnered",
    relationship: "good",
    satisfaction: "satisfied",
    libido: "normal",
    frequency: "weekly",
    orgasm: "yes",
    concerns: [],
    note: ""
  },

  environment: {                        // Environment card
    setting: "suburban",
    climate: "temperate",
    water: "reverse_osmosis",          // circadian-informed
    waterConcerns: [],
    emf: ["wifi", "smart_meter"],
    emfMitigation: ["router_timer"],
    homeLight: "led_warm",
    air: ["hepa_filter"],
    toxins: [],
    building: "modern",
    note: ""
  },

  // Full-width card — freetext string
  interpretiveLens: "Longevity medicine, quantum biology, functional endocrinology",

  // Health goals — priority-ordered array
  healthGoals: [
    { text: "Optimize testosterone naturally", severity: "major" },
    { text: "Improve sleep quality",           severity: "mild"  },
    { text: "Reduce inflammation markers",     severity: "minor" }
  ],

  // Free-form AI context notes — freetext string
  contextNotes: "Currently experimenting with carnivore diet.",

  // Menstrual cycle — null for male profiles
  menstrualCycle: {
    cycleLength: 28,           // days (auto-calculated if enough periods)
    periodLength: 5,           // days
    regularity: "regular",     // 'regular' | 'irregular' | 'very_irregular'
    flow: "moderate",
    contraceptive: "none",
    conditions: "none",
    periods: [
      {
        startDate: "2025-02-01",
        endDate:   "2025-02-06",
        flow: "moderate",
        symptoms: ["cramps", "fatigue"],
        notes: ""
      }
    ]
  },

  // Per-marker reference range overrides
  refOverrides: {
    "biochemistry.glucose": {
      refMin: 3.9,
      refMax: 5.6,
      optimalMin: 4.0,
      optimalMax: 5.0,
      labRefMin: 3.9,   // stashed lab-stated range from PDF import (for two-step revert)
      labRefMax: 5.6,    // preserved when user manually edits — revert goes lab → schema default
      refSource: "import" // "import" | "manual" — tracks who set the current refMin/refMax
    }
    // ... user-customized ranges from detail modal editing or import-time range adoption
  },

  // Display overrides for category labels (from rename)
  categoryLabels: {
    "mylab": "My Laboratory"  // categoryKey → display name
  },

  // Display overrides for category icons (from emoji picker)
  categoryIcons: {
    "mylab": "🧪"             // categoryKey → emoji
  },

  // Custom markers from PDF import — keyed by "category.markerKey"
  customMarkers: {
    "mylab.cortisol": {
      name: "Cortisol (AM)",
      unit: "nmol/L",
      refMin: 170,
      refMax: 720,
      categoryLabel: "My Lab"
    }
  },

  // Per-marker freeform notes — keyed by "category.markerKey".
  // What the marker means to YOU overall, not tied to any one reading.
  markerNotes: {
    "biochemistry.glucose": "Fasted samples only — non-fasted reads run high for me"
  },

  // Per-VALUE freeform notes — keyed by "category.markerKey:YYYY-MM-DD".
  // Context for a specific reading on a specific date (fasting status,
  // retake reason, lab change, etc.). Surfaced inline on the value card +
  // emitted as a dedicated AI-context section. Distinct from markerNotes
  // (overall) and entries.notes (date-level, not marker-specific).
  markerValueNotes: {
    "biochemistry.glucose:2024-03-14": "post-workout, blood draw 30 min after gym",
    "biochemistry.glucose:2024-04-02": "fasted 14h",
    "lipids.ldl:2024-04-02": "retake — first lab reported 5.2 mmol/L"
  },

  // Tombstone-set tracking which (marker, date) values were entered or
  // edited manually (vs imported from a PDF). Same colon-keying as
  // markerValueNotes. Value semantics:
  //   true  = manually added value (no original to revert to)
  //   number = the original SI value (set on first inline edit so a
  //            user can revert later)
  manualValues: {
    "biochemistry.glucose:2024-04-02": true,
    "lipids.ldl:2024-03-15": 3.2
  },

  // Timestamped snapshots of context field changes — appended by recordChange()
  changeHistory: [
    { field: "diet", date: "2026-03-01", snapshot: { type: "carnivore", ... } },
    { field: "stress", date: "2026-02-15", snapshot: { level: "moderate", ... } }
  ],

  // Agent Access profile-scoped state. Raw setup blobs are never stored;
  // this is the synced state used to reconnect browsers and re-push context.
  agentAccess: {
    enabled: true,
    token: "opaque-read-token",
    contextKey: "base64url-raw-256-bit-key",
    relayProfileId: "profile-id",
    updatedAt: 1782290000000,
    lastPushedAt: 1782290000000
  },
  agentAccessWearableSeriesDays: 30
}

genetics — DNA raw data (null if not imported)

genetics: {
  source: "AncestryDNA",       // provider name
  importDate: "2026-03-14",    // ISO date
  coverage: { found: 35, total: 41 },  // matched vs total curated SNPs
  apoe: "ε3/ε4",              // resolved haplotype (null if incomplete)
  snps: {
    "rs1801133": { genotype: "GA", gene: "MTHFR", variant: "C677T" },
    "rs1800562": { genotype: "GG", gene: "HFE", variant: "C282Y" },
    // ... only matched SNPs stored, not the full 600k+ raw file
  }
}
Re-import replaces entirely (no merge). Raw file is never stored — only matched SNPs.

IndexedDB — auto-backup

Database: labcharts-backups Object store: snapshots Key path: id (auto-increment) Each snapshot record:
{
  id: 42,                          // auto-increment
  createdAt: "2025-03-15T14:22:00.000Z",
  profileId: "default",
  data: {
    importedData: { ... },         // full importedData object
    unitSystem: "EU",
    rangeMode: "optimal",
    suppOverlayMode: "off",
    noteOverlay: "off",
    chatPersonality: "default",
    chatPersonalityCustom: null,
    // ... all per-profile preferences
    threadIndex: [...],            // chat thread index
    threads: {                     // per-thread messages (encrypted strings)
      "t_abc123": "...",
    }
  }
}
Maximum 5 snapshots are kept per profile. The oldest is pruned automatically on each new backup.

Encryption

When encryption is enabled (labcharts-encryption-enabled = 'true'), sensitive localStorage keys are stored as AES-256-GCM ciphertext (base64-encoded) instead of plaintext JSON. Encrypted key patterns (SENSITIVE_PATTERNS in crypto.js):
  • *-imported — all importedData
  • *-chat — legacy chat history
  • *-chat-t_* — per-thread messages
Keys that are not sensitive (preferences, model selections, pricing caches, profile index) are always stored in plaintext. The encryption key is derived via PBKDF2 from a user passphrase + per-profile salt (labcharts-{profileId}-enc-salt).

Hosted encrypted share records

Profile sharing is opt-in and uses hosted storage only after the user confirms upload in the Share Profile modal. The browser creates a normal v2 single-profile export, strips credential surfaces through buildClientExportObject(), compresses it when supported, and encrypts it locally before upload. The share password never leaves the browser and is not stored in the share link. Production records are stored by api/share.js in private Vercel Blob objects under profile-shares/v1/{id}.json. Production also stores small per-client fixed-slot rate-limit marker records under profile-share-rate/v1/{sha256-client}/{windowStart}/{slot}.json to add friction to anonymous share creation without relying on a read-then-write counter. Local development stores the same logical share record in the dev-server.js in-memory map.
{
  id: "base64url-share-id",
  createdAt: "2026-06-02T12:00:00.000Z",
  expiresAt: "2026-06-09T12:00:00.000Z",
  manageTokenHash: "sha256-hex-of-local-management-token",
  envelope: {
    schema: "getbased-profile-share",
    version: 1,
    createdAt: "2026-06-02T12:00:00.000Z",
    expiresAt: "2026-06-09T12:00:00.000Z",
    kdf: {
      name: "PBKDF2",
      hash: "SHA-256",
      iterations: 600000,
      salt: "base64url"
    },
    cipher: {
      name: "AES-GCM",
      iv: "base64url"
    },
    compression: "gzip", // or "none"
    ciphertext: "base64url"
  }
}
The remote record contains only ciphertext plus metadata needed to decrypt it in a recipient’s browser. The local active-link record contains the management token so the creating browser can delete the hosted envelope later; losing that browser-local record means the modal can no longer stop that link directly.