<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Anur</title>
    <description>The latest articles on DEV Community by Anur (@anur_78cb432c97ccd3189d99).</description>
    <link>https://dev.to/anur_78cb432c97ccd3189d99</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3416415%2F9d9c806b-732b-478d-a238-4cd02b1718aa.jpg</url>
      <title>DEV Community: Anur</title>
      <link>https://dev.to/anur_78cb432c97ccd3189d99</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anur_78cb432c97ccd3189d99"/>
    <language>en</language>
    <item>
      <title>Steam Backlog Helper: Tests and Minor improvements</title>
      <dc:creator>Anur</dc:creator>
      <pubDate>Tue, 19 Aug 2025 10:02:52 +0000</pubDate>
      <link>https://dev.to/anur_78cb432c97ccd3189d99/steam-backlog-helper-tests-and-minor-improvements-4ij6</link>
      <guid>https://dev.to/anur_78cb432c97ccd3189d99/steam-backlog-helper-tests-and-minor-improvements-4ij6</guid>
      <description>&lt;p&gt;I haven't been extremely consistent with this last blog post - I apologize, work has been intense! In this post I will be going through a couple Unit Tests (SOME because there is a LOT of them, far too many to go through them all), and discussing some minor features I've implemented since the last time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unit Tests
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Games Service&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Games Service is responsible for a lot of the core features of Steam Backlog Helper: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Store user's games&lt;/li&gt;
&lt;li&gt;Filter and accordingly assign games to the metadata worker&lt;/li&gt;
&lt;li&gt;Load games from the database (no metadata fetching)&lt;/li&gt;
&lt;li&gt;Get user's games with metadata&lt;/li&gt;
&lt;li&gt;Get user's games' genres&lt;/li&gt;
&lt;li&gt;Get recommended games (could and should probably move this to another module)&lt;/li&gt;
&lt;li&gt;Search games&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;First and foremost, I hardcoded some mock values that I will be using to test the service&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const testSteamId = '123456789';
const mockGames = [
        {
          appid: 1,
          name: 'Game A',
          genres: ['Action', 'RPG'],
          iscompleted: false,
          expanded: false,
          rating: 4.5
        },
        {
          appid: 2,
          name: 'Game B',
          genres: ['Action'],
          iscompleted: false,
          expanded: false,
          rating: 3
        },
        {
          appid: 3,
          name: 'Game C',
          genres: ['Puzzle'],
          iscompleted: true,
          expanded: false,
          rating: 5
        }
];
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, I mocked and imported all the necessary providers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;describe('GamesServiceService', () =&amp;gt; {
  let service: GamesServiceService;
  let ownedRepo: jest.Mocked&amp;lt;Repository&amp;lt;OwnedGame&amp;gt;&amp;gt;;
  let usersRepo: jest.Mocked&amp;lt;Repository&amp;lt;User&amp;gt;&amp;gt;;
  let gameMetadata: jest.Mocked&amp;lt;Repository&amp;lt;GameMetadata&amp;gt;&amp;gt;;
  let metadataQueue: MetadataQueue;

  beforeEach(async () =&amp;gt; {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        GamesServiceService,
        {
          provide: getRepositoryToken(OwnedGame),
          useValue: {
            save: jest.fn(),
            create: jest.fn(),
            insert: jest.fn(),
            find: jest.fn(),
            findOne: jest.fn(),
            update: jest.fn(),
            upsert: jest.fn(),
            createQueryBuilder: jest.fn()
          }
        },
        {
          provide: getRepositoryToken(User),
          useValue: {
            save: jest.fn(),
            create: jest.fn(),
            insert: jest.fn(),
            find: jest.fn(),
            findOne: jest.fn(),
            update: jest.fn()
          }
        },
        {
          provide: getRepositoryToken(GameMetadata),
          useValue: {
            save: jest.fn(),
            create: jest.fn(),
            insert: jest.fn(),
            find: jest.fn(),
            findOne: jest.fn(),
            update: jest.fn(),
            findBy: jest.fn()
          }
        },
        {
          provide: HttpService,
          useValue: {
            get: jest.fn(),
            post: jest.fn()
          }
        },
        {
          provide: MetadataQueue,
          useValue: {
            addFetchJob: jest.fn()
          }
        },
        {
          provide: ConfigService,
          useValue: {
            get: jest.fn()
          }
        }
      ],
    }).compile();

    service = module.get&amp;lt;GamesServiceService&amp;gt;(GamesServiceService);
    ownedRepo = module.get(getRepositoryToken(OwnedGame));
    gameMetadata = module.get(getRepositoryToken(GameMetadata));
    usersRepo = module.get(getRepositoryToken(User));
    metadataQueue = module.get(MetadataQueue);
  });
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the setup is complete, we can move on to the actual tests.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should throw error when user has no games
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;it('should throw error when user has no games', async () =&amp;gt; {
      jest.spyOn(usersRepo, 'findOne').mockResolvedValue(null);
      jest.spyOn(usersRepo, 'create').mockReturnValue({ id: 123, steam_id: testSteamId } as User);
      jest.spyOn(usersRepo, 'save').mockResolvedValue({ id: 123, steam_id: testSteamId } as User);

      jest.spyOn(service['http'], 'get').mockReturnValue({
        toPromise: jest.fn(),
      } as any);

      jest.spyOn(require('rxjs'), 'firstValueFrom').mockResolvedValue({
        data: {
          response: {
            games: []
          }
        }
      });

      await expect(service.fetchAndStoreUserGames(testSteamId)).rejects.toThrow('User has no existing metadata');
  });
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one is pretty simple. First we mockResolvedValue for the methods the service is going to call. Simply put, we are telling the repo and service what they are going to return once they've been called. We return &lt;code&gt;null&lt;/code&gt; when the service searches for a user to check if it actually enters this condition and then force it to create a new one - one that we mocked earlier.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftopuqyop3sjie62eq7ga.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftopuqyop3sjie62eq7ga.png" alt=" " width="800" height="140"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Should create user if missing on fetch &amp;amp; store user games
&lt;/h3&gt;

&lt;p&gt;This one's a bit larger, but not too complicated either. The beginning is similar to the previous test, we check if it creates a user if one does not already exist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jest.spyOn(usersRepo, 'findOne').mockResolvedValue(null);
    jest.spyOn(usersRepo, 'create').mockReturnValue({ id: 123, steam_id: testSteamId } as User);
    jest.spyOn(usersRepo, 'save').mockResolvedValue({ id: 123, steam_id: testSteamId } as User);
    jest.spyOn(service['http'], 'get').mockReturnValue({
      toPromise: jest.fn(),
    } as any);

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next step: mock the Steam API response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const fakeGame = {
      appid: 1,
      playtime_forever: 100,
      rtime_last_played: 1234567890,
    };

    jest.spyOn(require('rxjs'), 'firstValueFrom').mockResolvedValue({
      data: {
        response: {
          games: [fakeGame]
        }
      }
    });
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We can do this because we are spying on the &lt;code&gt;http.get()&lt;/code&gt; request, which is present in the service, as seen below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const response = await firstValueFrom(
      this.http.get&amp;lt;GameResponse&amp;gt;(
        'https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/',
        {
          params: {
            key: this.config.get('STEAM_KEY'),
            steamid: steamId,
            include_appinfo: 1,
            format: 'json',
          },
        },
      ),
    );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After mocking a new user, and mocking a response from Steam API, we have to check the next step: adding a game to user's owned games as well as adding a job to the worker. Couple of lines of code do this for us:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jest.spyOn(ownedRepo, 'upsert').mockResolvedValue({} as any);
    jest.spyOn(gameMetadata, 'findBy').mockResolvedValue([]);
    jest.spyOn(metadataQueue, 'addFetchJob').mockResolvedValue(undefined);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Last but not least, let's call the function and check if everything lines up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const result = await service.fetchAndStoreUserGames(testSteamId);
    expect(usersRepo.create).toHaveBeenCalledWith({ steam_id: testSteamId});
    expect(usersRepo.save).toHaveBeenCalledTimes(1);
    expect(ownedRepo.upsert).toHaveBeenCalled();
    expect(metadataQueue.addFetchJob).toHaveBeenCalledTimes(1);
    expect(result).toHaveLength(1);
    expect(result[0]).toMatchObject({
      appid: fakeGame.appid,
      playtime_minutes: fakeGame.playtime_forever,
      loadingMetadata: true,
      expanded: false,
    });
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As you can see, we expect that a new user should be created with our fake steam id, saved only once | that one job should be added to the metadata queue, and result (user's games with metadata) to hold an object which matches our mocked game (+ some extra fields that it gets assigned in the service).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4w2nwn0zohxoim8ha0cw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4w2nwn0zohxoim8ha0cw.png" alt=" " width="800" height="150"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are 4 more tests in this suite, and a total of 16(&lt;strong&gt;!&lt;/strong&gt;) suites in total, so writing about all of them here would take ages. However, I suppose you get the gist from these few. &lt;/p&gt;

&lt;p&gt;All controllers and services are tested in SBH.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minor changes &amp;amp; new features
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Editing profile&lt;/strong&gt;&lt;br&gt;
Currently, we only support editing the description, however in the near future, we will support changing the gif next to user information.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9f630ld39amrc5t3j3so.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9f630ld39amrc5t3j3so.png" alt=" " width="800" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Game Status Overriding&lt;/strong&gt;&lt;br&gt;
Implement functionality to override the automatically determined completion status, in order to improve recommendation results and reduce clutter&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fakd3asv8as7pmxzc96w2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fakd3asv8as7pmxzc96w2.png" alt=" " width="800" height="565"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Game Details Page&lt;/strong&gt;&lt;br&gt;
Finished the Game Details Page, where users can see pictures and videos from the selected games and some additional data (such as their progress)!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyuca67ns1n0d2pgmydk0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyuca67ns1n0d2pgmydk0.png" alt=" " width="800" height="312"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk04uwcizsgb6lbcfb3ke.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk04uwcizsgb6lbcfb3ke.png" alt=" " width="800" height="124"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I think this nicely sums up the latest changes. I'll write soon, when I have even more features and hopefully soon once SBH is live!&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nestjs</category>
      <category>javascript</category>
      <category>angular</category>
    </item>
    <item>
      <title>Steam Backlog Helper: Frontend | Part 1</title>
      <dc:creator>Anur</dc:creator>
      <pubDate>Thu, 07 Aug 2025 10:32:42 +0000</pubDate>
      <link>https://dev.to/anur_78cb432c97ccd3189d99/steam-backlog-helper-frontend-part-1-1ead</link>
      <guid>https://dev.to/anur_78cb432c97ccd3189d99/steam-backlog-helper-frontend-part-1-1ead</guid>
      <description>&lt;p&gt;Welcome to the second post about Steam Backlog Helper! As promised previously, in this blog I will discuss the current state of the app's FE, and hopefully from this point on I will just be writing regular updates, briefly detailing the latest changes and progress.&lt;/p&gt;

&lt;h2&gt;
  
  
  Home
&lt;/h2&gt;

&lt;p&gt;I wanted the Home Page to be inviting, motivating (to play games) but also not too much. The main job for this page is to invite users to engage with the website (through Steam log in).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flx3rpvqfekkjr5v23ukj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flx3rpvqfekkjr5v23ukj.png" alt=" " width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Went for the pixel font for the Home page as it seems aligned with the nature of this service. The background is also a video, that loops and is muted. You will notice the sprite (hooded character) standing on the letter 'R'. Upon clicking it, it will jump! This was simple, just replace the idle animation with the jumping animation (animations' duration considered).&lt;br&gt;
Sidenote: The logo in the nav is still a work in progress so please don't judge me 😅.&lt;/p&gt;

&lt;p&gt;Moving on, after the user goes through standard steam sign up we are greeted by the callback page, which also fetches the user's games and their respective data (in the background). &lt;/p&gt;

&lt;h2&gt;
  
  
  Dashboard
&lt;/h2&gt;

&lt;p&gt;Here is the currently main page of the service: a place where the users can see all of their games, filter or sort them and search for titles too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9fg0tn7h0ukztfa5g8js.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9fg0tn7h0ukztfa5g8js.png" alt=" " width="800" height="386"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Starting from top to bottom, we see the small user overview. Currently, it just houses the user's steam username, steam_id (clickable link pointing to their steam profile), small description and (currently non-functional) edit and share buttons. Next to this is a small gif, currently hardcoded to a single file. In the future, I would like to gamify this platform, thus allowing the users to unlock different gifs/animations that they could use instead of this one.&lt;/p&gt;

&lt;p&gt;Below this is the search/filtering section.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc3x0t16400d8vxtc08i6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc3x0t16400d8vxtc08i6.png" alt=" " width="800" height="157"&gt;&lt;/a&gt;&lt;br&gt;
Simple enough, there's a search bar, and genres (extracted from the users' games), which , upon clicking, sort the cards correspondingly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Game cards
&lt;/h2&gt;

&lt;p&gt;These cards hold information about specific owned games: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Genres&lt;/li&gt;
&lt;li&gt;Categories&lt;/li&gt;
&lt;li&gt;Playtime&lt;/li&gt;
&lt;li&gt;Completion status&lt;/li&gt;
&lt;li&gt;Estimated time to complete&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2dd9snn6fpvcn1rew3c7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2dd9snn6fpvcn1rew3c7.png" alt=" " width="800" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is important not to forget the check mark and cancel buttons on the side of the cards - they are used for manual completion status corrections.&lt;/p&gt;

&lt;h2&gt;
  
  
  Top Picks For You
&lt;/h2&gt;

&lt;p&gt;This page presents you with top picks based on a combination of your most-played genres and user reception / ratings. There will still be more polishing of the logic behind this recommended queue in the future, for example not recommending (or timing) massively multiplayer games because technically can't 'beat' them (I have CS2 in this screenshot with 600+ hours). If you want to read more about the actual logic of this, refer to my previous post: &lt;a href="https://dev.to/anur_78cb432c97ccd3189d99/project-progress-steam-backlog-helper-34p6"&gt;Project Progress: Steam Backlog Helper&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fctli90n8ulaojrhf1orv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fctli90n8ulaojrhf1orv.png" alt=" " width="800" height="296"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;As you can see, none of these games are completed and they are accurate. I was actually ecstatic to see Ark: Survival Evolved as my No. 1 since it completely aligns with what I like in games: dinosaurs/diverse creatures and crafting! As a matter of fact, after writing this blog I'll be hopping right in!&lt;/p&gt;

&lt;p&gt;This concludes the current progress for SBH, I will keep writing about my future updates and features. As always, feedback is appreciated!&lt;/p&gt;

&lt;p&gt;Until next time :)&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>angular</category>
      <category>javascript</category>
      <category>design</category>
    </item>
    <item>
      <title>Project progress: Steam Backlog Helper</title>
      <dc:creator>Anur</dc:creator>
      <pubDate>Wed, 06 Aug 2025 08:26:22 +0000</pubDate>
      <link>https://dev.to/anur_78cb432c97ccd3189d99/project-progress-steam-backlog-helper-34p6</link>
      <guid>https://dev.to/anur_78cb432c97ccd3189d99/project-progress-steam-backlog-helper-34p6</guid>
      <description>&lt;p&gt;If you've ever had a huge backlog of potentially amazing games on Steam, look no further because I (might) have a solution for you :)&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Steam Backlog Helper is a website I made to solve an issue many people experience, myself included - the endless backlog of games on Steam you can never quite get around to finishing. Scrolling through my library, there are many games I haven't even touched, and every single one of those could be a memorable experience.&lt;/p&gt;

&lt;p&gt;This is why I decided to start my own project to solve this issue, which is where Steam Backlog Helper comes in. It is being built completely in Angular 18, with a NestJs (TypeScript) backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does it work?
&lt;/h2&gt;

&lt;p&gt;Steam Backlog Helper (SBH for short) contains all of your steam library, along with game metadata and your personal stats regarding those games. This includes your favorite genres of games, your playtime for each game, time required to complete games, tags &amp;amp; categories of games and a recommendation page. &lt;/p&gt;

&lt;p&gt;You initally log in with Steam from our Home Page, after which you are redirected to the dashboard/games page. Once you log in with Steam, the backend does several things: &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetch user library data from Steam:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const response = await firstValueFrom(
      this.http.get&amp;lt;GameResponse&amp;gt;(
        'https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/',
        {
          params: {
            steamid: steamId,
            include_appinfo: 1,
            format: 'json',
          },
        },
      ),
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Omitted the key (even though it's grabbed by the Config service) for security purposes. Unfortunately for me, Steam API returns a very surface-level response regarding the users' games - which makes sense, given that there is a LOT of data to process. We receive data in this format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"game_count": 193,
        "games": [
            {
                "appid": 4000,
                "playtime_forever": 5794,
                "playtime_windows_forever": 0,
                "playtime_mac_forever": 0,
                "playtime_linux_forever": 0,
                "playtime_deck_forever": 0,
                "rtime_last_played": 1542490381,
                "playtime_disconnected": 0
            },...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As you can see, we only have playtime &amp;amp; appid, but for SBH we need more data: Title, Description, Genres, Categories, Completion status etc.&lt;/p&gt;

&lt;p&gt;At this point, we move on to step 2:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Associate games with user in owned_games DB table:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;for (const g of games) {

      await this.ownedRepo.upsert(
        {
          user: { id: user.id },
          appid: g.appid,
          playtime_minutes: g.playtime_forever,
          last_played: g.rtime_last_played
            ? new Date(g.rtime_last_played * 1000)
            : null,
        },
        ['user', 'appid'],
      );
    }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is pretty self-explanatory, we just write the user's games to the owned_games database. With this data in place, we can move on to the next step.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check and insert MetaData if required
In this step, I go through all of the games and check them across another table: game_metadata. This table contains associations with games (appid) and their respective metadata which I collect.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In order to determine which games are missing metadata, I use a &lt;code&gt;Set&lt;/code&gt;. I collect all the appids which are in game_metadata, and isolate them in this set, so that I could later compare them to all of the appids, thus effectively extracting missing metadata.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const appIds = games.map((g: Game) =&amp;gt; g.appid);
    const existingMetadata = await this.metadataRepo.findBy({
      appid: In&amp;lt;Number&amp;gt;(appIds),
    });

    const existingAppIds = new Set(existingMetadata.map((m: GameMetadata) =&amp;gt; m.appid));
    const missingAppIds = appIds.filter((appid: number) =&amp;gt; !existingAppIds.has(appid));

    for (const appid of missingAppIds) {
      await this.metadataQueue.addFetchJob(appid);
    }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last part is interesting -&amp;gt; &lt;code&gt;await this.metadataQueue.addFetchJob(appid);&lt;/code&gt;. In order to keep the website  stable, and also allow the user to use the website while loading, I delegate the job of scraping and finding metadata to a BullMQ worker!&lt;br&gt;
This worker, once assigned a job, goes on to find and insert metadata into the correct table, in the background.&lt;/p&gt;

&lt;p&gt;In my worker service, I have defined the following function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;async getGameDetails(appid: number){
        const obs = this.http.get&amp;lt;GameDetailsResponse&amp;gt;(`https://store.steampowered.com/api/appdetails`, {
            params: { appids: appid, l: 'english'}, headers: {
                'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
                '(KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36',
                'Accept': 'application/json'
            }
        }).pipe(
            map(response =&amp;gt; response.data)
        );
        return firstValueFrom(obs);
    }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All it does is it checks the game against the Steam Store API (api.steampowered != store.steampowered.com/api IMPORTANT), which does have a lot of the metadata I need. The extra headers are to avoid timeouts and blocks from the Store API (which has happened several times before). &lt;/p&gt;

&lt;p&gt;Now that we understand where and how the worker gets the metadata, let's move on to the job itself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;async job =&amp;gt; {
                const {appid} = job.data;
                await this.sleep(2000);
                let data: GameDetailsResponse;
                try {
                    data = await this.workerService.getGameDetails(appid);
                } catch (err) {
                    console.error(`Failed to fetch Steam API for appid ${appid}`, err.message);
                    return;
                }...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You might notice the 2 second sleep/delay, this is due to the API restrictions on Steam. After acquiring metadata from Steam Store, I then find the community reported time-to-beat. Originally, I've used IGDB and their API for this, however, it had plenty of gaps and was messy to use for me. Instead, I opted for RAWG. RAWG DB has a great amount of games and the data is excellent. &lt;/p&gt;

&lt;p&gt;The worker fetches the time-to-beat data as following:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;     const hltb = await this.hltbService.getGameTime(appData.name);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's called &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;hltb&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;as an association to 'HowLongToBeat'. Next, it saves the metadata and calculates if the user completed the game (which it saves to the owned_games repo).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;try {
                    await this.metadataRepo.save({
                        appid,
                        name: appData.name,
                        genres: appData.genres?.map((g: Genre) =&amp;gt; g.description),
                        categories: appData.categories?.map((c: Genre) =&amp;gt; c.description),
                        tags: [],
                        last_fetched: new Date(),
                        header_image: appData.header_image,
                        description: appData.short_description,
                        hltb_main_story: hltb
                    });

                    try {
                        const game = await this.ownedGameRepo.findOne({
                            where: {
                                appid: appid
                            }
                        });

                        if(!game){
                            console.log('Could not get game from ownedgames table');
                            return;
                        }

                        game.isCompleted = this.getIsCompleted(game.playtime_minutes, hltb);
                        await this.ownedGameRepo.save(game);
                    } catch (error){
                        console.log('Error saving isCompleted: ', error);
                    }
                    console.log('Saved metadata for: ', appData.name);
                    } catch (err) {
                      console.log(`Failed to save metadata for appid ${appid}`, err);
                    }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pretty much concludes the core of the worker. There are also fail-safes and conditions for re-fetching in the service (once a week and if certain fields are missing).&lt;/p&gt;

&lt;p&gt;The cool thing about this setup is that once a game's metadata is loaded, it's universal, meaning new users have to wait less time after signing up if their games' metadata is already collected.&lt;/p&gt;

&lt;p&gt;After the initial log in / sign up, the user does not have to go through steam sign up until their token (given by SBH) expires. During this period, their games do not need to be assigned to the worker or fetched etc, instead, the data is pulled from the database, resulting in very fast response times.&lt;/p&gt;

&lt;p&gt;Once in the dashboard, the user will see all of their games, paginated and sorted with appropriate tags: Genres, Playtime status (Never played, Less than 3hr played...) etc. Given the nature of Steam, online &amp;amp; offline play there is room for error when assessing whether or not a user has completed a game. To combat this, within the game card, the user is given an option to correct the status, marking a game either completed or not completed (in addition to the automatically calculated expectation).&lt;/p&gt;

&lt;h2&gt;
  
  
  Top Picks For You / Recommendations
&lt;/h2&gt;

&lt;p&gt;This functionality extracts and ranks game choices for the user. Currently, it uses only vectors and weights to make this assumption, however, in the future it is very likely going to use some form of ML.&lt;/p&gt;

&lt;p&gt;The logic for this is actually not complex and I will go through it quickly: &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Get all user's games and extract genre counts&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Simple, get all games and order genres in an Object which contains the number of instances a genre has appeared.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const genreCount: Record&amp;lt;string, number&amp;gt; = {};
    games.forEach(g =&amp;gt; {
      if (Array.isArray(g.genres)){
        g.genres.forEach((genre: string) =&amp;gt; {
        genreCount[genre] = (genreCount[genre] || 0) + 1;
      });
    }
    });
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we calculate the total for each genre, and create a vector - this vector normalizes genre counts by dividing with total count. This gives us 'user preference weights', and looks a bit like &lt;code&gt;{RPG: 0.5, MMO: 0.2...}&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const total = Object.values(genreCount).reduce((a,b) =&amp;gt; a+b, 0);
    const userVector = Object.fromEntries(Object.entries(genreCount).map(([k,v]) =&amp;gt; [k, v/total]));
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this data in place, we then create a gVector which assigns the value 1 to each genre present in the game. It then calculates the users' preference and assigns score:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const score = Object.keys(userVector).reduce(
        (sum, genre) =&amp;gt; sum + (userVector[genre] || 0) * (gVector[genre] || 0),
        0
      );
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Effectively, the score is the sum of the user's preference weights. This score could potentially be RPG:0.5 + Action: 0.2 = 0.7 for example. &lt;/p&gt;

&lt;p&gt;This worked fine, and the scores worked as expected. However, there was a slight issue: Many of the games recommended, despite having the "correct" genres, were also very, very small indie games or even abandoned projects. From personal experience, I found that in order to engage a user in a game, a big community and a good reception are paramount.&lt;/p&gt;

&lt;p&gt;So, I had to introduce a new parameter: Rating. Thankfully, steam store does provide us with ratings, so after a slight modification to my table, I was able to provide ratings to game metadata. Now my goal was the following: after assessing the genre score, add the rating score to the mix as well. However, there's a catch here too: some games have an IMMENSE amount of reviews, which completely skewed the weights. I balanced it out by using logarithms on the ratings themselves, and even new weights on the two parameters: genre rating and review rating:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;return scored
    .filter(g =&amp;gt; !g.isCompleted)
    .map(g =&amp;gt; {
      const ratingVal = g.rating &amp;amp;&amp;amp; g.rating &amp;gt; 0 ? Math.log(g.rating) / 5 : 0;
      return {
        ...g,
        combinedScore: (g.score * 0.6) + (ratingVal*0.4)
      }
    })
    .sort((a,b) =&amp;gt; b.combinedScore - a.combinedScore)
    .slice(0, Number(amount) || 10);
  }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This, in turn, has produced great results but there is a lot of room for improvement. There is much more code I haven't mentioned here as I'm trying to keep this as concise as possible. In the future posts, I will dive into explaining the Frontend (what I have so far) but also future features I have planned for SBH.&lt;/p&gt;

&lt;p&gt;I also am very welcoming to any sort of advice, feedback or even critics.&lt;br&gt;
Stay tuned, until next time :)&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>fullstack</category>
      <category>angular</category>
      <category>nestjs</category>
    </item>
  </channel>
</rss>
