Repository: DestinyItemManager/DIM
Pull request #5590 (2020-08-13 07:32:47)

More refactor
Some more breaking stuff out into separate files to make dependencies untangle.


Patch:
diff --git a/config/i18n.json b/config/i18n.json
index b1bf0e372..6fd373150 100644
--- a/config/i18n.json
+++ b/config/i18n.json
@@ -70,7 +70,7 @@
     "ItemUniquenessExplanation_male": "You tried to move the '{{name}}' {{type}} to your {{character}} but that destination already has that item and is only allowed one.",
     "Maintenance": "Bungie.net servers are down for maintenance.",
     "NetworkError": "Network error - {{status}} {{statusText}}",
-    "NoAccount": "No Destiny account was found on {{platform}}. Do you have the right platform selected?",
+    "NoAccount": "No Destiny account was found. Do you have the right platform selected?",
     "NoAccountForPlatform": "Failed to find a Destiny account for you on {{platform}}.",
     "NotConnected": "You may not be connected to the internet.",
     "NotConnectedOrBlocked": "You may not be connected to the internet, or an ad blocking or privacy extension may be blocking Bungie.net.",
diff --git a/config/webpack.js b/config/webpack.js
index f9c4b3a88..233eac395 100644
--- a/config/webpack.js
+++ b/config/webpack.js
@@ -396,8 +396,6 @@ module.exports = (env) => {
         '$featureFlags.issueBanner': JSON.stringify(true),
         // Show the triage tab in the item popup
         '$featureFlags.triage': JSON.stringify(env.dev),
-        // Detach stats from the sticky header on mobile
-        '$featureFlags.unstickyStats': JSON.stringify(!env.release),
       }),
 
       new WorkerPlugin({
diff --git a/src/app/accounts/get-active-platform.ts b/src/app/accounts/get-active-platform.ts
new file mode 100644
index 000000000..ef5b45dec
--- /dev/null
+++ b/src/app/accounts/get-active-platform.ts
@@ -0,0 +1,8 @@
+import { DestinyAccount } from './destiny-account';
+import store from '../store/store';
+import { currentAccountSelector } from './selectors';
+
+// TODO: get rid of this - we don't want to implicitly depend on store
+export function getActivePlatform(): DestinyAccount | undefined {
+  return currentAccountSelector(store.getState());
+}
diff --git a/src/app/accounts/platforms.ts b/src/app/accounts/platforms.ts
index ed601a6b6..809e891f5 100644
--- a/src/app/accounts/platforms.ts
+++ b/src/app/accounts/platforms.ts
@@ -6,7 +6,6 @@ import {
 } from './destiny-account';
 import { getBungieAccount } from './bungie-account';
 import * as actions from './actions';
-import store from '../store/store';
 import { loadingTracker } from '../shell/loading-tracker';
 import { goToLoginPage } from '../bungie-api/authenticated-fetch';
 import { accountsSelector, currentAccountSelector, accountsLoadedSelector } from './selectors';
@@ -79,11 +78,6 @@ function loadAccountsFromBungieNet(): ThunkResult<readonly DestinyAccount[]> {
   return loadAccountsFromBungieNetAction;
 }
 
-// TODO: get rid of this
-export function getActivePlatform(): DestinyAccount | undefined {
-  return currentAccountSelector(store.getState());
-}
-
 export function setActivePlatform(
   account: DestinyAccount | undefined
 ): ThunkResult<DestinyAccount | undefined> {
diff --git a/src/app/bungie-api/bungie-service-helper.ts b/src/app/bungie-api/bungie-service-helper.ts
index 8cf290af3..9b8b02072 100644
--- a/src/app/bungie-api/bungie-service-helper.ts
+++ b/src/app/bungie-api/bungie-service-helper.ts
@@ -2,7 +2,6 @@ import { PlatformErrorCodes, ServerResponse } from 'bungie-api-ts/common';
 import { HttpClientConfig } from 'bungie-api-ts/http';
 import { t } from 'app/i18next-t';
 import { API_KEY } from './bungie-api-utils';
-import { getActivePlatform } from '../accounts/platforms';
 import { fetchWithBungieOAuth, goToLoginPage } from './authenticated-fetch';
 import { rateLimitedFetch } from './rate-limiter';
 import { stringify } from 'simple-query-string';
@@ -200,13 +199,7 @@ export async function handleErrors<T>(response: Response): Promise<ServerRespons
 
     case PlatformErrorCodes.DestinyAccountNotFound:
       if (response.url.indexOf('/Account/') >= 0 && response.url.indexOf('/Character/') < 0) {
-        const account = getActivePlatform();
-        throw error(
-          t('BungieService.NoAccount', {
-            platform: account ? t(`Accounts.${account.platformLabel}`) : 'Unknown',
-          }),
-          errorCode
-        );
+        throw error(t('BungieService.NoAccount'), errorCode);
       } else {
         throw error(t('BungieService.Difficulties'), errorCode);
       }
diff --git a/src/app/bungie-api/destiny1-api.ts b/src/app/bungie-api/destiny1-api.ts
index b7af45a63..319806f78 100644
--- a/src/app/bungie-api/destiny1-api.ts
+++ b/src/app/bungie-api/destiny1-api.ts
@@ -2,7 +2,7 @@ import { t } from 'app/i18next-t';
 import _ from 'lodash';
 import { bungieApiQuery, bungieApiUpdate } from './bungie-api-utils';
 import { error, httpAdapter, handleUniquenessViolation } from './bungie-service-helper';
-import { getActivePlatform } from '../accounts/platforms';
+import { getActivePlatform } from '../accounts/get-active-platform';
 import { DestinyManifest, ServerResponse, PlatformErrorCodes } from 'bungie-api-ts/destiny2';
 import { D1Store, DimStore } from '../inventory/store-types';
 import { DestinyAccount } from '../accounts/destiny-account';
diff --git a/src/app/bungie-api/destiny2-api.ts b/src/app/bungie-api/destiny2-api.ts
index d6e9b2258..b4167bfb2 100644
--- a/src/app/bungie-api/destiny2-api.ts
+++ b/src/app/bungie-api/destiny2-api.ts
@@ -30,7 +30,7 @@ import { t } from 'app/i18next-t';
 import _ from 'lodash';
 import { DestinyAccount } from '../accounts/destiny-account';
 import { httpAdapter, handleUniquenessViolation } from './bungie-service-helper';
-import { getActivePlatform } from '../accounts/platforms';
+import { getActivePlatform } from '../accounts/get-active-platform';
 import { DimItem } from '../inventory/item-types';
 import { DimStore } from '../inventory/store-types';
 import { reportException } from '../utils/exceptions';
diff --git a/src/app/character-tile/CharacterTile.scss b/src/app/character-tile/CharacterTile.scss
index 2ef8b5d10..50728fabb 100644
--- a/src/app/character-tile/CharacterTile.scss
+++ b/src/app/character-tile/CharacterTile.scss
@@ -28,9 +28,6 @@
     .vault & {
       flex-direction: row;
       align-items: center;
-      @include phone-portrait {
-        align-items: flex-start;
-      }
     }
 
     .app-icon {
@@ -94,7 +91,7 @@
 
   .top,
   .bottom {
-    flex: 1;
+    // flex: 1;
     display: flex;
     flex-wrap: nowrap;
     flex-direction: row;
@@ -106,28 +103,11 @@
     font-size: 20px;
     font-weight: 400;
     height: 24px;
-
-    @include phone-portrait {
-      .maxTotalPower {
-        font-size: 12px;
-        align-self: flex-end;
-        padding-left: 4px;
-      }
-    }
   }
-}
 
-.bottom {
-  font-size: 12px;
-  color: white;
-  height: 14px;
-  @include phone-portrait {
-    .vault & {
-      display: grid;
-      grid-template-columns: repeat(2, 16px minmax(-webkit-min-content, 1fr));
-      grid-template-columns: repeat(2, 16px minmax(min-content, 1fr));
-      grid-auto-rows: 16px;
-      gap: 3px 2px;
-    }
+  .bottom {
+    font-size: 12px;
+    color: white;
+    height: 14px;
   }
 }
diff --git a/src/app/character-tile/CharacterTile.tsx b/src/app/character-tile/CharacterTile.tsx
index ae85bb7cf..863504ace 100644
--- a/src/app/character-tile/CharacterTile.tsx
+++ b/src/app/character-tile/CharacterTile.tsx
@@ -1,10 +1,7 @@
 import React from 'react';
-import { useSelector } from 'react-redux';
 import clsx from 'clsx';
-import { AppIcon, powerActionIcon } from 'app/shell/icons';
-import { isPhonePortraitSelector } from 'app/inventory/selectors';
-import type { DimStore, DimVault } from 'app/inventory/store-types';
-import VaultCapacity from 'app/store-stats/VaultCapacity';
+import { DimStore } from '../inventory/store-types';
+import { AppIcon, powerActionIcon } from '../shell/icons';
 import './CharacterTile.scss';
 
 const CharacterEmblem = ({ store }: { store: DimStore }) => (
@@ -14,18 +11,11 @@ const CharacterEmblem = ({ store }: { store: DimStore }) => (
   />
 );
 
-function isVault(store: DimStore): store is DimVault {
-  return store.isVault;
-}
-
 /**
  * Render a basic character tile without any event handlers
  * This is currently being shared between StoreHeading and CharacterTileButton
  */
 export default function CharacterTile({ store }: { store: DimStore }) {
-  const maxTotalPower = Math.floor(store.stats?.maxTotalPower?.value || store.powerLevel);
-  const isPhonePortrait = useSelector(isPhonePortraitSelector);
-
   return (
     <div className="character-tile">
       <div className="background" style={{ backgroundImage: `url("${store.background}")` }} />
@@ -34,27 +24,18 @@ export default function CharacterTile({ store }: { store: DimStore }) {
         <div className="top">
           <div className="class">{store.className}</div>
           {!store.isVault && (
-            <>
-              <div className="powerLevel">
-                <AppIcon icon={powerActionIcon} />
-                {store.powerLevel}
-              </div>
-              {$featureFlags.unstickyStats && isPhonePortrait && (
-                <div className="maxTotalPower">/ {maxTotalPower}</div>
-              )}
-            </>
-          )}
-        </div>
-        <div className="bottom">
-          {isVault(store) ? (
-            <>{$featureFlags.unstickyStats && isPhonePortrait && <VaultCapacity store={store} />}</>
-          ) : (
-            <>
-              <div className="race-gender">{store.genderRace}</div>
-              {store.isDestiny1() && store.level < 40 && <div className="level">{store.level}</div>}
-            </>
+            <div className="powerLevel">
+              <AppIcon icon={powerActionIcon} />
+              {store.powerLevel}
+            </div>
           )}
         </div>
+        {!store.isVault && (
+          <div className="bottom">
+            <div className="race-gender">{store.genderRace}</div>
+            {store.isDestiny1() && store.level < 40 && <div className="level">{store.level}</div>}
+          </div>
+        )}
       </div>
     </div>
   );
diff --git a/src/app/character-tile/StoreHeading.scss b/src/app/character-tile/StoreHeading.scss
index c2a4998c7..deafbc1fc 100644
--- a/src/app/character-tile/StoreHeading.scss
+++ b/src/app/character-tile/StoreHeading.scss
@@ -10,9 +10,6 @@
 
   &.vault {
     width: calc(6px + var(--character-column-width) - var(--item-margin));
-    @include phone-portrait {
-      width: auto;
-    }
   }
 
   &.current:before {
diff --git a/src/app/destiny1/d1-bucket-categories.ts b/src/app/destiny1/d1-bucket-categories.ts
new file mode 100644
index 000000000..4a059cd75
--- /dev/null
+++ b/src/app/destiny1/d1-bucket-categories.ts
@@ -0,0 +1,19 @@
+export const D1Categories = {
+  Postmaster: ['LostItems', 'SpecialOrders', 'Messages'],
+  Weapons: ['Class', 'Primary', 'Special', 'Heavy'],
+  Armor: ['Helmet', 'Gauntlets', 'Chest', 'Leg', 'ClassItem'],
+  General: [
+    'Artifact',
+    'Ghost',
+    'Consumable',
+    'Material',
+    'Ornaments',
+    'Emblem',
+    'Shader',
+    'Emote',
+    'Ship',
+    'Vehicle',
+    'Horn',
+  ],
+  Progress: ['Bounties', 'Quests', 'Missions'],
+};
diff --git a/src/app/destiny1/d1-buckets.ts b/src/app/destiny1/d1-buckets.ts
index 13686aa4c..674a24de5 100644
--- a/src/app/destiny1/d1-buckets.ts
+++ b/src/app/destiny1/d1-buckets.ts
@@ -2,26 +2,7 @@ import _ from 'lodash';
 import { getDefinitions } from './d1-definitions';
 import { InventoryBuckets, InventoryBucket } from '../inventory/inventory-buckets';
 import { BucketCategory } from 'bungie-api-ts/destiny2';
-
-export const D1Categories = {
-  Postmaster: ['LostItems', 'SpecialOrders', 'Messages'],
-  Weapons: ['Class', 'Primary', 'Special', 'Heavy'],
-  Armor: ['Helmet', 'Gauntlets', 'Chest', 'Leg', 'ClassItem'],
-  General: [
-    'Artifact',
-    'Ghost',
-    'Consumable',
-    'Material',
-    'Ornaments',
-    'Emblem',
-    'Shader',
-    'Emote',
-    'Ship',
-    'Vehicle',
-    'Horn',
-  ],
-  Progress: ['Bounties', 'Quests', 'Missions'],
-};
+import { D1Categories } from './d1-bucket-categories';
 
 // A mapping from the bucket hash to DIM item types
 const bucketToType = {
diff --git a/src/app/destiny1/loadout-builder/GeneratedSet.tsx b/src/app/destiny1/loadout-builder/GeneratedSet.tsx
index 6c354a9d9..a7a454e09 100644
--- a/src/app/destiny1/loadout-builder/GeneratedSet.tsx
+++ b/src/app/destiny1/loadout-builder/GeneratedSet.tsx
@@ -7,7 +7,7 @@ import { DimStore } from '../../inventory/store-types';
 import { t } from 'app/i18next-t';
 import LoadoutBuilderItem from './LoadoutBuilderItem';
 import { AppIcon, faMinusSquare, faPlusSquare } from '../../shell/icons';
-import CharacterStats from '../../store-stats/CharacterStats';
+import CharacterStats from '../../inventory/CharacterStats';
 import ItemTalentGrid from '../../item-popup/ItemTalentGrid';
 import { newLoadout, convertToLoadoutItem } from '../../loadout/loadout-utils';
 import { editLoadout } from 'app/loadout/LoadoutDrawer';
diff --git a/src/app/destiny2/d2-bucket-categories.ts b/src/app/destiny2/d2-bucket-categories.ts
new file mode 100644
index 000000000..bd5269175
--- /dev/null
+++ b/src/app/destiny2/d2-bucket-categories.ts
@@ -0,0 +1,7 @@
+export const D2Categories = {
+  Postmaster: ['Engrams', 'LostItems', 'Messages', 'SpecialOrders'],
+  Weapons: ['Class', 'Kinetic', 'Energy', 'Power', 'SeasonalArtifacts'],
+  Armor: ['Helmet', 'Gauntlets', 'Chest', 'Leg', 'ClassItem'],
+  General: ['Ghost', 'ClanBanners', 'Vehicle', 'Ships', 'Emblems', 'Finishers'],
+  Inventory: ['Consumables', 'Modifications', 'Shaders'],
+};
diff --git a/src/app/destiny2/d2-buckets.ts b/src/app/destiny2/d2-buckets.ts
index 813669142..c96716671 100644
--- a/src/app/destiny2/d2-buckets.ts
+++ b/src/app/destiny2/d2-buckets.ts
@@ -3,15 +3,7 @@ import _ from 'lodash';
 import { getDefinitions } from './d2-definitions';
 import { InventoryBuckets, InventoryBucket } from '../inventory/inventory-buckets';
 import { VENDORS } from 'app/search/d2-known-values';
-
-// TODO: We can generate this based on making a tree from DestinyItemCategoryDefinitions
-export const D2Categories = {
-  Postmaster: ['Engrams', 'LostItems', 'Messages', 'SpecialOrders'],
-  Weapons: ['Class', 'Kinetic', 'Energy', 'Power', 'SeasonalArtifacts'],
-  Armor: ['Helmet', 'Gauntlets', 'Chest', 'Leg', 'ClassItem'],
-  General: ['Ghost', 'ClanBanners', 'Vehicle', 'Ships', 'Emblems', 'Finishers'],
-  Inventory: ['Consumables', 'Modifications', 'Shaders'],
-};
+import { D2Categories } from './d2-bucket-categories';
 
 // A mapping from the bucket hash to DIM item types
 const bucketToType: { [hash: number]: string | undefined } = {
diff --git a/src/app/destinyTrackerApi/d2-reviewsFetcher.ts b/src/app/destinyTrackerApi/d2-reviewsFetcher.ts
index 61383f7da..647dd25f8 100644
--- a/src/app/destinyTrackerApi/d2-reviewsFetcher.ts
+++ b/src/app/destinyTrackerApi/d2-reviewsFetcher.ts
@@ -1,5 +1,5 @@
 import _ from 'lodash';
-import { getActivePlatform } from '../accounts/platforms';
+import { getActivePlatform } from '../accounts/get-active-platform';
 import { loadingTracker } from '../shell/loading-tracker';
 import { handleD2Errors } from './d2-trackerErrorHandler';
 import { D2Item } from '../inventory/item-types';
diff --git a/src/app/dim-ui/FractionalPowerLevel.tsx b/src/app/dim-ui/FractionalPowerLevel.tsx
index 29a0bdfd4..8b0355209 100644
--- a/src/app/dim-ui/FractionalPowerLevel.tsx
+++ b/src/app/dim-ui/FractionalPowerLevel.tsx
@@ -1,7 +1,7 @@
 import React from 'react';
 import styles from './FractionalPowerLevel.m.scss';
 
-export default function FractionalPowerLevel({ power }: { power: number }) {
+export function FractionalPowerLevel({ power }: { power: number }) {
   const numerator = (power * 8) % 8 || null;
   return (
     <span className={styles.fractionalPowerLevel}>
diff --git a/src/app/gear-power/GearPower.tsx b/src/app/gear-power/GearPower.tsx
index 419ab4501..6c2632b6a 100644
--- a/src/app/gear-power/GearPower.tsx
+++ b/src/app/gear-power/GearPower.tsx
@@ -13,7 +13,7 @@ import { getLight } from 'app/loadout/loadout-utils';
 import BucketIcon from 'app/dim-ui/svgs/BucketIcon';
 import BungieImage from 'app/dim-ui/BungieImage';
 import { itemPop } from 'app/dim-ui/scroll';
-import FractionalPowerLevel from 'app/dim-ui/FractionalPowerLevel';
+import { FractionalPowerLevel } from 'app/dim-ui/FractionalPowerLevel';
 import clsx from 'clsx';
 
 const bucketClassNames = {
diff --git a/src/app/store-stats/CharacterStats.tsx b/src/app/inventory/CharacterStats.tsx
similarity index 78%
rename from src/app/store-stats/CharacterStats.tsx
rename to src/app/inventory/CharacterStats.tsx
index 16a50d27f..4b222144b 100644
--- a/src/app/store-stats/CharacterStats.tsx
+++ b/src/app/inventory/CharacterStats.tsx
@@ -1,16 +1,16 @@
 import React from 'react';
+import { DimCharacterStat, DimStore } from './store-types';
 import clsx from 'clsx';
-import _ from 'lodash';
+import PressTip from '../dim-ui/PressTip';
 import { t } from 'app/i18next-t';
-import type { DestinyVersion } from '@destinyitemmanager/dim-api-types';
-import type { DimCharacterStat, DimStore } from 'app/inventory/store-types';
-import FractionalPowerLevel from 'app/dim-ui/FractionalPowerLevel';
-import PressTip from 'app/dim-ui/PressTip';
-import { percent } from 'app/shell/filters';
+import './dimStats.scss';
+import { percent } from '../shell/filters';
+import _ from 'lodash';
+import { armorStats } from './store/stats';
+import { getD1CharacterStatTiers, statsWithTiers } from './store/character-utils';
+import { DestinyVersion } from '@destinyitemmanager/dim-api-types';
 import { showGearPower } from 'app/gear-power/gear-power';
-import { armorStats } from 'app/inventory/store/stats';
-import { getD1CharacterStatTiers, statsWithTiers } from 'app/inventory/store/character-utils';
-import './CharacterStats.scss';
+import { FractionalPowerLevel } from 'app/dim-ui/FractionalPowerLevel';
 
 interface Props {
   stats?: DimStore['stats'];
@@ -18,30 +18,6 @@ interface Props {
   storeId?: string;
 }
 
-function CharacterStat({ stat }: { stat: DimCharacterStat }) {
-  return (
-    <>
-      <img src={stat.icon} alt={stat.name} />
-      <div>
-        {stat.hash < 0 ? (
-          <span className="powerStat">
-            <FractionalPowerLevel power={stat.value} />
-          </span>
-        ) : (
-          stat.value
-        )}
-        {(stat.hasClassified || stat.differentEquippableMaxGearPower) && (
-          <sup className="asterisk">*</sup>
-        )}
-      </div>
-    </>
-  );
-}
-
-/**
- * Render the character information: Max Power/Stat points.
- * May want to consider splitting D1 from D2 at some point.
- */
 export default React.memo(function CharacterStats({ stats, destinyVersion, storeId }: Props) {
   if (!stats) {
     return null;
@@ -129,6 +105,14 @@ export default React.memo(function CharacterStats({ stats, destinyVersion, store
         {[powerInfos, statInfos].map((stats, index) => (
           <div key={index} className="stat-row">
             {stats.map(({ stat, tooltip }) => {
+              const displayValue =
+                stat.hash < 0 ? (
+                  <span className="powerStat">
+                    <FractionalPowerLevel power={stat.value} />
+                  </span>
+                ) : (
+                  stat.value
+                );
               // if this is the "max gear power" stat (hash -3),
               // add in an onClick and an extra class
               const isMaxGearPower = stat.hash === -3 && storeId;
@@ -147,7 +131,13 @@ export default React.memo(function CharacterStats({ stats, destinyVersion, store
                         : undefined
                     }
                   >
-                    <CharacterStat stat={stat} />
+                    <img src={stat.icon} alt={stat.name} />
+                    <div>
+                      {displayValue}
+                      {(stat.hasClassified || stat.differentEquippableMaxGearPower) && (
+                        <sup className="asterisk">*</sup>
+                      )}
+                    </div>
                   </div>
                 </PressTip>
               );
diff --git a/src/app/inventory/Stores.scss b/src/app/inventory/Stores.scss
index 54e2077a4..da8c02f3c 100644
--- a/src/app/inventory/Stores.scss
+++ b/src/app/inventory/Stores.scss
@@ -173,7 +173,10 @@
     .store-cell {
       margin: 0;
       width: 100%;
-      padding: 8px 0;
+      padding: 8px 0 4px 0;
+      .character.vault {
+        width: 100%;
+      }
     }
     > div {
       max-width: calc(100% - 100px);
diff --git a/src/app/inventory/Stores.tsx b/src/app/inventory/Stores.tsx
index 3cf13fe16..47794f45a 100644
--- a/src/app/inventory/Stores.tsx
+++ b/src/app/inventory/Stores.tsx
@@ -16,8 +16,9 @@ import { hideItemPopup } from '../item-popup/item-popup';
 import { storeBackgroundColor } from '../shell/filters';
 import InventoryCollapsibleTitle from './InventoryCollapsibleTitle';
 import clsx from 'clsx';
+import CharacterStats from './CharacterStats';
+import VaultStats from './VaultStats';
 import { getCurrentStore, getVault, getStore } from './stores-helpers';
-import StoreStats from 'app/store-stats/StoreStats';
 
 interface StoreProps {
   stores: DimStore[];
@@ -109,7 +110,15 @@ function Stores(this: void, { stores, buckets, isPhonePortrait }: Props) {
                       onTapped={setSelectedStoreId}
                       loadoutMenuRef={detachedLoadoutMenu}
                     />
-                    {!$featureFlags.unstickyStats && <StoreStats store={store} />}
+                    {isVault(store) ? (
+                      <VaultStats store={store} />
+                    ) : (
+                      <CharacterStats
+                        destinyVersion={store.destinyVersion}
+                        stats={store.stats}
+                        storeId={store.id}
+                      />
+                    )}
                   </View>
                 ))}
               </Track>
@@ -121,12 +130,6 @@ function Stores(this: void, { stores, buckets, isPhonePortrait }: Props) {
 
         <Hammer direction="DIRECTION_HORIZONTAL" onSwipe={handleSwipe}>
           <div>
-            {$featureFlags.unstickyStats && (
-              <StoreStats
-                store={selectedStore}
-                style={storeBackgroundColor(selectedStore, 0, true)}
-              />
-            )}
             <StoresInventory
               stores={[selectedStore]}
               vault={vault}
@@ -153,7 +156,15 @@ function Stores(this: void, { stores, buckets, isPhonePortrait }: Props) {
             style={storeBackgroundColor(store, index)}
           >
             <StoreHeading store={store} />
-            <StoreStats store={store} />
+            {isVault(store) ? (
+              <VaultStats store={store} />
+            ) : (
+              <CharacterStats
+                destinyVersion={store.destinyVersion}
+                stats={store.stats}
+                storeId={store.id}
+              />
+            )}
           </div>
         ))}
       </ScrollClassDiv>
@@ -183,6 +194,10 @@ function categoryHasItems(
 
 export default connect<StoreProps>(mapStateToProps)(Stores);
 
+function isVault(store: DimStore): store is DimVault {
+  return store.isVault;
+}
+
 function StoresInventory({
   buckets,
   stores,
diff --git a/src/app/inventory/VaultStats.m.scss b/src/app/inventory/VaultStats.m.scss
new file mode 100644
index 000000000..043034714
--- /dev/null
+++ b/src/app/inventory/VaultStats.m.scss
@@ -0,0 +1,48 @@
+.vaultStats {
+  max-width: 230px;
+  display: grid;
+  grid-template-columns: repeat(4, 16px minmax(min-content, 1fr));
+  grid-auto-rows: 16px;
+  gap: 3px 2px;
+  align-items: center;
+  font-size: 11px;
+  color: #aaa;
+  margin-top: 8px;
+
+  img,
+  .bucketTag {
+    justify-self: center;
+  }
+
+  :global(.phone-portrait) & {
+    margin-left: auto;
+    margin-right: auto;
+  }
+}
+
+img.currency {
+  height: 16px;
+  width: 16px;
+}
+
+.bucketTag {
+  font-weight: bold;
+  font-size: 14px;
+  line-height: 14px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #888;
+  img {
+    height: 14px;
+    width: 14px;
+    filter: invert(1);
+    opacity: 0.5;
+  }
+}
+
+.full {
+  color: #e12929;
+  font-weight: bold;
+  text-shadow: 0 0 3px rgba(0, 0, 0, 0.3);
+}
diff --git a/src/app/store-stats/StoreStats.m.scss.d.ts b/src/app/inventory/VaultStats.m.scss.d.ts
similarity index 84%
rename from src/app/store-stats/StoreStats.m.scss.d.ts
rename to src/app/inventory/VaultStats.m.scss.d.ts
index 0b9ff546a..7ceeafd97 100644
--- a/src/app/store-stats/StoreStats.m.scss.d.ts
+++ b/src/app/inventory/VaultStats.m.scss.d.ts
@@ -2,6 +2,8 @@
 // Please do not change this file!
 interface CssExports {
   'bucketTag': string;
+  'currency': string;
+  'full': string;
   'vaultStats': string;
 }
 export const cssExports: CssExports;
diff --git a/src/app/store-stats/VaultCapacity.tsx b/src/app/inventory/VaultStats.tsx
similarity index 64%
rename from src/app/store-stats/VaultCapacity.tsx
rename to src/app/inventory/VaultStats.tsx
index 213302585..2d2f611e2 100644
--- a/src/app/store-stats/VaultCapacity.tsx
+++ b/src/app/inventory/VaultStats.tsx
@@ -1,12 +1,13 @@
 import React from 'react';
+import { DimVault } from './store-types';
 import clsx from 'clsx';
+import BungieImage from 'app/dim-ui/BungieImage';
 import _ from 'lodash';
-import type { DimVault } from 'app/inventory/store-types';
+import styles from './VaultStats.m.scss';
 import modificationsIcon from 'destiny-icons/general/modifications.svg';
 import shadersIcon from 'destiny-icons/general/shaders2.svg';
 import consumablesIcon from 'destiny-icons/general/consumables.svg';
 import vaultIcon from 'destiny-icons/armor_types/helmet.svg';
-import styles from './VaultCapacity.m.scss';
 
 const bucketIcons = {
   3313201758: modificationsIcon,
@@ -14,7 +15,6 @@ const bucketIcons = {
   1469714392: consumablesIcon,
   138197802: vaultIcon,
 };
-
 const vaultBucketOrder = [
   // D1
   3003523923, // Armor
@@ -28,10 +28,27 @@ const vaultBucketOrder = [
   2973005342,
 ];
 
-/** Current amounts and maximum capacities of the vault */
-export default function VaultCapacity({ store }: { store: DimVault }) {
+export default function VaultStats({ store }: { store: DimVault }) {
   return (
-    <>
+    <div className={styles.vaultStats}>
+      {store.currencies.map((currency) => (
+        <React.Fragment key={currency.itemHash}>
+          <BungieImage
+            className={styles.currency}
+            src={currency.displayProperties.icon}
+            title={currency.displayProperties.name}
+          />
+          <div className={styles.currency} title={currency.displayProperties.name}>
+            {currency.quantity.toLocaleString()}
+          </div>
+        </React.Fragment>
+      ))}
+      {_.times(4 - store.currencies.length, (i) => (
+        <React.Fragment key={i}>
+          <div />
+          <div />
+        </React.Fragment>
+      ))}
       {_.sortBy(Object.keys(store.vaultCounts), (id) =>
         vaultBucketOrder.indexOf(parseInt(id, 10))
       ).map((bucketId) => (
@@ -54,6 +71,6 @@ export default function VaultCapacity({ store }: { store: DimVault }) {
           </div>
         </React.Fragment>
       ))}
-    </>
+    </div>
   );
 }
diff --git a/src/app/store-stats/CharacterStats.scss b/src/app/inventory/dimStats.scss
similarity index 100%
rename from src/app/store-stats/CharacterStats.scss
rename to src/app/inventory/dimStats.scss
diff --git a/src/app/inventory/selectors.ts b/src/app/inventory/selectors.ts
index 6618e9de1..edb86f85b 100644
--- a/src/app/inventory/selectors.ts
+++ b/src/app/inventory/selectors.ts
@@ -18,8 +18,6 @@ export const sortedStoresSelector = createSelector(
   (stores, sortStores) => sortStores(stores)
 );
 
-export const isPhonePortraitSelector = (state: RootState) => state.shell.isPhonePortrait;
-
 /** Have stores been loaded? */
 export const storesLoadedSelector = (state: RootState) => storesSelector(state).length > 0;
 
diff --git a/src/app/inventory/spreadsheets.ts b/src/app/inventory/spreadsheets.ts
index 436561f0e..ee6968549 100644
--- a/src/app/inventory/spreadsheets.ts
+++ b/src/app/inventory/spreadsheets.ts
@@ -12,7 +12,7 @@ import { DtrRating } from '../item-review/dtr-api-types';
 import Papa from 'papaparse';
 import { ThunkResult } from 'app/store/types';
 import _ from 'lodash';
-import { getActivePlatform } from '../accounts/platforms';
+import { getActivePlatform } from '../accounts/get-active-platform';
 import { getClass } from './store/character-utils';
 import { download } from 'app/utils/util';
 import { getRating } from '../item-review/reducer';
diff --git a/src/app/item-review/destiny-tracker.service.ts b/src/app/item-review/destiny-tracker.service.ts
index ab0513f8c..7d965d6e6 100644
--- a/src/app/item-review/destiny-tracker.service.ts
+++ b/src/app/item-review/destiny-tracker.service.ts
@@ -1,5 +1,5 @@
 import { getItemReviewsD1 } from '../destinyTrackerApi/reviewsFetcher';
-import { getActivePlatform } from '../accounts/platforms';
+import { getActivePlatform } from '../accounts/get-active-platform';
 import {
   bulkFetch as bulkFetchD2,
   bulkFetchVendorItems as bulkFetchD2VendorItems,
diff --git a/src/app/loadout/loadout-utils.ts b/src/app/loadout/loadout-utils.ts
index dca5cc8b5..e5efdbaf1 100644
--- a/src/app/loadout/loadout-utils.ts
+++ b/src/app/loadout/loadout-utils.ts
@@ -4,7 +4,7 @@ import { DimItem } from '../inventory/item-types';
 import { v4 as uuidv4 } from 'uuid';
 import { DimStore } from 'app/inventory/store-types';
 import { DestinyClass } from 'bungie-api-ts/destiny2';
-import { D2Categories } from '../destiny2/d2-buckets';
+import { D2Categories } from '../destiny2/d2-bucket-categories';
 
 const excludeGearSlots = ['Class', 'SeasonalArtifacts'];
 // order to display a list of all 8 gear slots
diff --git a/src/app/progress/SolsticeOfHeroes.tsx b/src/app/progress/SolsticeOfHeroes.tsx
index bdbebce7f..a71a899d2 100644
--- a/src/app/progress/SolsticeOfHeroes.tsx
+++ b/src/app/progress/SolsticeOfHeroes.tsx
@@ -7,7 +7,7 @@ import ErrorBoundary from 'app/dim-ui/ErrorBoundary';
 import { D2EventEnum } from 'data/d2/d2-event-info';
 import { DimItem } from 'app/inventory/item-types';
 import { D2ManifestDefinitions } from 'app/destiny2/d2-definitions';
-import { D2Categories } from 'app/destiny2/d2-buckets';
+import { D2Categories } from 'app/destiny2/d2-bucket-categories';
 import Objective from './Objective';
 import './SolsticeOfHeroes.scss';
 
diff --git a/src/app/search/search-config.ts b/src/app/search/search-config.ts
index a4f3104d4..7a9424a26 100644
--- a/src/app/search/search-config.ts
+++ b/src/app/search/search-config.ts
@@ -1,8 +1,8 @@
 import { itemTagSelectorList } from '../inventory/dim-item-info';
 import { modSlotTags } from 'app/utils/item-utils';
 
-import { D1Categories } from '../destiny1/d1-buckets';
-import { D2Categories } from '../destiny2/d2-buckets';
+import { D1Categories } from '../destiny1/d1-bucket-categories';
+import { D2Categories } from '../destiny2/d2-bucket-categories';
 import { D2EventPredicateLookup } from 'data/d2/d2-event-info';
 import D2Sources from 'data/d2/source-info';
 import _ from 'lodash';
diff --git a/src/app/settings/SettingsPage.tsx b/src/app/settings/SettingsPage.tsx
index 5f6fd7cb0..83ea19c24 100644
--- a/src/app/settings/SettingsPage.tsx
+++ b/src/app/settings/SettingsPage.tsx
@@ -18,7 +18,8 @@ import { D2StoresService } from '../inventory/d2-stores';
 import { D1StoresService } from '../inventory/d1-stores';
 import Checkbox from './Checkbox';
 import Select, { mapToOptions, listToOptions } from './Select';
-import { getPlatforms, getActivePlatform } from '../accounts/platforms';
+import { getPlatforms } from '../accounts/platforms';
+import { getActivePlatform } from '../accounts/get-active-platform';
 import { itemSortOrder } from './item-sort';
 import { Settings } from './initial-settings';
 import { settingsSelector } from './reducer';
diff --git a/src/app/store-stats/AccountCurrencies.m.scss b/src/app/store-stats/AccountCurrencies.m.scss
deleted file mode 100644
index f48e24bb5..000000000
--- a/src/app/store-stats/AccountCurrencies.m.scss
+++ /dev/null
@@ -1,4 +0,0 @@
-img.currency {
-  height: 16px;
-  width: 16px;
-}
diff --git a/src/app/store-stats/AccountCurrencies.m.scss.d.ts b/src/app/store-stats/AccountCurrencies.m.scss.d.ts
deleted file mode 100644
index 06919af39..000000000
--- a/src/app/store-stats/AccountCurrencies.m.scss.d.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-// This file is automatically generated.
-// Please do not change this file!
-interface CssExports {
-  'currency': string;
-}
-export const cssExports: CssExports;
-export default cssExports;
diff --git a/src/app/store-stats/AccountCurrencies.tsx b/src/app/store-stats/AccountCurrencies.tsx
deleted file mode 100644
index cbb80d81e..000000000
--- a/src/app/store-stats/AccountCurrencies.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from 'react';
-import _ from 'lodash';
-import BungieImage from 'app/dim-ui/BungieImage';
-import type { DimVault } from '../inventory/store-types';
-import styles from './AccountCurrencies.m.scss';
-
-/** The account currencies (glimmer, shards, etc.) */
-export default function AccountCurrency({ store }: { store: DimVault }) {
-  return (
-    <>
-      {store.currencies.map((currency) => (
-        <React.Fragment key={currency.itemHash}>
-          <BungieImage
-            className={styles.currency}
-            src={currency.displayProperties.icon}
-            title={currency.displayProperties.name}
-          />
-          <div className={styles.currency} title={currency.displayProperties.name}>
-            {currency.quantity.toLocaleString()}
-          </div>
-        </React.Fragment>
-      ))}
-      {_.times(4 - store.currencies.length, (i) => (
-        <React.Fragment key={i}>
-          <div />
-          <div />
-        </React.Fragment>
-      ))}
-    </>
-  );
-}
diff --git a/src/app/store-stats/StoreStats.m.scss b/src/app/store-stats/StoreStats.m.scss
deleted file mode 100644
index 17bd89930..000000000
--- a/src/app/store-stats/StoreStats.m.scss
+++ /dev/null
@@ -1,22 +0,0 @@
-.vaultStats {
-  max-width: 230px;
-  display: grid;
-  grid-template-columns: repeat(4, 16px minmax(min-content, 1fr));
-  grid-auto-rows: 16px;
-  gap: 3px 2px;
-  align-items: center;
-  font-size: 11px;
-  color: #aaa;
-  margin-top: 8px;
-
-  img,
-  .bucketTag {
-    justify-self: center;
-  }
-
-  :global(.phone-portrait) & {
-    flex: 1;
-    margin-left: auto;
-    margin-right: auto;
-  }
-}
diff --git a/src/app/store-stats/StoreStats.tsx b/src/app/store-stats/StoreStats.tsx
deleted file mode 100644
index eb2367e06..000000000
--- a/src/app/store-stats/StoreStats.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import React from 'react';
-import { useSelector } from 'react-redux';
-import clsx from 'clsx';
-import type { DimStore, DimVault } from 'app/inventory/store-types';
-import CharacterStats from '../store-stats/CharacterStats';
-import AccountCurrencies from './AccountCurrencies';
-import VaultCapacity from './VaultCapacity';
-import styles from './StoreStats.m.scss';
-import { isPhonePortraitSelector } from 'app/inventory/selectors';
-
-function isVault(store: DimStore): store is DimVault {
-  return store.isVault;
-}
-
-function shouldShowCapacity(isPhonePortrait: boolean) {
-  if (!isPhonePortrait) {
-    return true;
-  }
-  return !$featureFlags.unstickyStats;
-}
-
-/** Render the store stats for any store type (character or vault) */
-export default function StoreStats({
-  store,
-  style,
-}: {
-  store: DimStore;
-  style?: React.CSSProperties;
-}) {
-  const isPhonePortrait = useSelector(isPhonePortraitSelector);
-  return (
-    <div className={clsx({ ['store-cell']: Boolean(style), vault: store.isVault })} style={style}>
-      {isVault(store) ? (
-        <div className={styles.vaultStats}>
-          <AccountCurrencies store={store} />
-          {shouldShowCapacity(isPhonePortrait) && <VaultCapacity store={store} />}
-        </div>
-      ) : (
-        <CharacterStats
-          destinyVersion={store.destinyVersion}
-          stats={store.stats}
-          storeId={store.id}
-        />
-      )}
-    </div>
-  );
-}
diff --git a/src/app/store-stats/VaultCapacity.m.scss b/src/app/store-stats/VaultCapacity.m.scss
deleted file mode 100644
index 9f49d045f..000000000
--- a/src/app/store-stats/VaultCapacity.m.scss
+++ /dev/null
@@ -1,21 +0,0 @@
-.bucketTag {
-  font-weight: bold;
-  font-size: 14px;
-  line-height: 14px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #888;
-  img {
-    height: 14px;
-    width: 14px;
-    filter: invert(1);
-    opacity: 0.5;
-  }
-}
-
-.full {
-  color: #e12929;
-  font-weight: bold;
-  text-shadow: 0 0 3px rgba(0, 0, 0, 0.3);
-}
diff --git a/src/app/store-stats/VaultCapacity.m.scss.d.ts b/src/app/store-stats/VaultCapacity.m.scss.d.ts
deleted file mode 100644
index 5ff7f8ab6..000000000
--- a/src/app/store-stats/VaultCapacity.m.scss.d.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-// This file is automatically generated.
-// Please do not change this file!
-interface CssExports {
-  'bucketTag': string;
-  'full': string;
-}
-export const cssExports: CssExports;
-export default cssExports;
diff --git a/src/global.d.ts b/src/global.d.ts
index 8e049b927..2d4c6656f 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -36,8 +36,6 @@ declare const $featureFlags: {
   issueBanner: boolean;
   // Show the triage tab in the item popup
   triage: boolean;
-  /** Enable detached stats from sticky header on mobile */
-  unstickyStats: boolean;
 };
 
 declare namespace React {
diff --git a/src/locale/dim.json b/src/locale/dim.json
index 7f016ea01..2b71bdeed 100644
--- a/src/locale/dim.json
+++ b/src/locale/dim.json
@@ -70,7 +70,7 @@
     "ItemUniquenessExplanation_male": "You tried to move the '{{name}}' {{type}} to your {{character}} but that destination already has that item and is only allowed one.",
     "Maintenance": "Bungie.net servers are down for maintenance.",
     "NetworkError": "Network error - {{status}} {{statusText}}",
-    "NoAccount": "No Destiny account was found on {{platform}}. Do you have the right platform selected?",
+    "NoAccount": "No Destiny account was found. Do you have the right platform selected?",
     "NoAccountForPlatform": "Failed to find a Destiny account for you on {{platform}}.",
     "NotConnected": "You may not be connected to the internet.",
     "NotConnectedOrBlocked": "You may not be connected to the internet, or an ad blocking or privacy extension may be blocking Bungie.net.",


Test patch:
diff --git a/src/app/dim-ui/CharacterSelect.test.tsx b/src/app/dim-ui/CharacterSelect.test.tsx
new file mode 100644
index 000000000..36d71d491
--- /dev/null
+++ b/src/app/dim-ui/CharacterSelect.test.tsx
@@ -0,0 +1,128 @@
+import React from 'react';
+import TestRenderer from 'react-test-renderer';
+import CharacterSelect from './CharacterSelect';
+import { DimStore } from '../inventory/store-types';
+import { Frame, Track, ViewPager } from 'react-view-pager';
+import CharacterTileButton from 'app/character-tile/CharacterTileButton';
+
+const stubDimStore: DimStore = {} as DimStore;
+const stubDimStoreArray: DimStore[] = [];
+const dimStore1: DimStore = {} as DimStore;
+const dimStore2: DimStore = {} as DimStore;
+const dimStore3: DimStore = {} as DimStore;
+const mockOnCharacterChanged = jest.fn();
+
+beforeEach(() => {
+  dimStore1.id = '2';
+  dimStore1.isDestiny1 = () => false;
+  dimStore1.isDestiny2 = () => true;
+  dimStore1.isVault = false;
+  dimStore2.id = '3';
+  dimStore2.isDestiny1 = () => false;
+  dimStore2.isDestiny2 = () => true;
+  dimStore2.isVault = true;
+  dimStore3.id = '4';
+  dimStore3.isDestiny1 = () => false;
+  dimStore3.isDestiny2 = () => true;
+  dimStore3.isVault = false;
+  stubDimStoreArray[0] = dimStore1;
+  stubDimStoreArray[1] = dimStore2;
+  stubDimStoreArray[2] = dimStore3;
+});
+it('should render horizontal phone interface ', () => {
+  const testRenderer = TestRenderer.create(
+    <CharacterSelect
+      stores={stubDimStoreArray}
+      selectedStore={stubDimStore}
+      vertical={false}
+      isPhonePortrait={true}
+      onCharacterChanged={mockOnCharacterChanged}
+    />
+  );
+  const testInstance = testRenderer.root;
+  // If ViewPager, Frame, and Track are not null, we have entered the correct branch
+  expect(testInstance.findByType(ViewPager)).not.toBeNull();
+  expect(testInstance.findByType(Frame)).not.toBeNull();
+  expect(testInstance.findByType(Track)).not.toBeNull();
+});
+
+it('should render the non-phone interface ', () => {
+  const testRenderer = TestRenderer.create(
+    <CharacterSelect
+      stores={stubDimStoreArray}
+      selectedStore={stubDimStore}
+      vertical={false}
+      isPhonePortrait={false}
+      onCharacterChanged={mockOnCharacterChanged}
+    />
+  );
+  const testInstance = testRenderer.root;
+  // If ViewPager, Frame, and Track are not there, we have entered the correct branch
+  expect(testInstance.findAllByType(ViewPager)).toHaveLength(0);
+  expect(testInstance.findAllByType(Frame)).toHaveLength(0);
+  expect(testInstance.findAllByType(Track)).toHaveLength(0);
+});
+
+it('should render all non-vault stores in the phone interface', () => {
+  const testRenderer = TestRenderer.create(
+    <CharacterSelect
+      stores={stubDimStoreArray}
+      selectedStore={stubDimStore}
+      vertical={false}
+      isPhonePortrait={true}
+      onCharacterChanged={mockOnCharacterChanged}
+    />
+  );
+  const testInstance = testRenderer.root;
+  // There are two non-vault mocked DimStore objects. Both should render.
+  expect(testInstance.findAllByType(CharacterTileButton).length).toBe(2);
+});
+
+it('should render all non-vault stores in the non-phone interface', () => {
+  const testRenderer = TestRenderer.create(
+    <CharacterSelect
+      stores={stubDimStoreArray}
+      selectedStore={stubDimStore}
+      vertical={false}
+      isPhonePortrait={false}
+      onCharacterChanged={mockOnCharacterChanged}
+    />
+  );
+  const testInstance = testRenderer.root;
+  // There are two non-vault mocked DimStore objects. Both should render.
+  expect(testInstance.findAllByType(CharacterTileButton).length).toBe(2);
+});
+
+it('should call the onCharacterChanged function on click in the phone interface', () => {
+  const testRenderer = TestRenderer.create(
+    <CharacterSelect
+      stores={stubDimStoreArray}
+      selectedStore={stubDimStore}
+      vertical={false}
+      isPhonePortrait={true}
+      onCharacterChanged={mockOnCharacterChanged}
+    />
+  );
+  const testInstance = testRenderer.root;
+  // Simulate a click event
+  testInstance.findAllByType(CharacterTileButton)[0].props.onClick();
+  expect(mockOnCharacterChanged).toHaveBeenCalledTimes(1);
+  jest.clearAllMocks();
+});
+
+it('should call the onCharacterChanged function on click in the non-phone interface', () => {
+  const testRenderer = TestRenderer.create(
+    <CharacterSelect
+      stores={stubDimStoreArray}
+      selectedStore={stubDimStore}
+      vertical={false}
+      isPhonePortrait={false}
+      onCharacterChanged={mockOnCharacterChanged}
+    />
+  );
+  const testInstance = testRenderer.root;
+  // Simulate a click event
+  testInstance.findAllByType(CharacterTileButton)[0].props.onClick();
+  expect(mockOnCharacterChanged).toHaveBeenCalledTimes(1);
+  jest.clearAllMocks();
+});