← Back to all posts

Building the Archive: Replaying Past Daily Challenges

The Problem: "I Missed Yesterday!"

One of the most common pieces of feedback I received from PriceGuess players was frustration about missing daily challenges. Unlike Wordle, where missing a day just means missing that word, PriceGuess features real products with real prices from Israeli supermarkets. Players wanted to go back and try challenges they had missed - or replay ones they had lost.

Building this seemingly simple feature turned out to be one of the trickiest parts of the project, primarily because of how the scraper stores data. Let me walk you through the architecture, the bugs, and the lessons learned.

Architecture: How Daily Challenges Work

Every day, a CRON Worker scrapes approximately 10 random products from Shufersal (Israel's largest supermarket chain) and stores them in a D1 database table called daily_challenges. Each row has a challenge_date and an item_code, with a unique constraint on the pair.

The daily challenge page calls getTodayChallenge(), which queries:

SELECT challenge_date, item_name, target_price, ...
FROM daily_challenges
WHERE challenge_date = ?
ORDER BY id ASC
LIMIT 1

The ORDER BY id ASC LIMIT 1 is key - it ensures that among the ~10 products scraped for any given day, the same product is always selected as "the" daily challenge. This is deterministic: every player sees the same product on the same day.

The Archive API

For the archive, I built two API endpoints:

GET /api/archive/challenges - Returns the list of past challenges (last 90 days) with the user's play status (won, lost, or not played). This feeds the archive calendar UI.

GET /api/archive/[date] - Returns the full challenge data for a specific date, including the product name, image, unit, and target price. The game component uses this to render the same gameplay experience as the daily challenge.

The Three Bugs That Broke Everything

When I first deployed the archive, it looked correct at a glance - but users quickly reported that something was very wrong. Three bugs, all stemming from the same root cause.

Bug 1: Every Entry Showed the Same Date

The archive list showed 90 entries, but they all displayed the same date even though they had different product names. The original query was:

SELECT dc.challenge_date, dc.item_name, ...
FROM daily_challenges dc
WHERE dc.challenge_date < ?
ORDER BY dc.challenge_date DESC
LIMIT 90

The problem? With 10 products per day, this returns 10 rows for each day. So the 90-row limit meant I was getting about 9 days of data, with each day repeated 10 times. Since each row had a different item_name but the same challenge_date, users saw what appeared to be the same date over and over.

Bug 2: All Entries Showed "Lost" After Playing One

After playing one archive challenge, every entry in the list turned yellow (indicating a loss). This happened because all entries for the same date shared the same localStorage key: priceguess_daily_archive_2026-03-10. Playing any entry for that date set the key, which the calendar checked for all entries of the same date.

Bug 3: Different Dates Opened the Same Game

Clicking on different entries opened the same game every time. Since all entries for a date had the same challenge_date, the API endpoint /api/archive/[date] received the same date parameter and returned the same product (via LIMIT 1).

The Fix: One Entry Per Date

The root cause was clear: the archive API needed to return exactly one entry per date - the same product that was selected as the daily challenge. The fix used a subquery to find the first product per date:

SELECT dc.challenge_date, ...
FROM daily_challenges dc
INNER JOIN (
  SELECT challenge_date, MIN(id) as min_id
  FROM daily_challenges
  WHERE challenge_date < ?
  GROUP BY challenge_date
) first_per_day ON dc.id = first_per_day.min_id
ORDER BY dc.challenge_date DESC
LIMIT 90

This INNER JOIN with MIN(id) mirrors the daily challenge selection logic (ORDER BY id ASC LIMIT 1), ensuring consistency. I also added ORDER BY id ASC to the archive detail endpoint.

Bonus Fix: Database Persistence

I noticed another subtle bug: when playing an archive challenge, the game component sent the challengeDate as archive_2026-03-10 (with the archive_ prefix) to the guess persistence API. This meant the guesses table stored archive_2026-03-10 as the challenge date, which never matched the daily_challenges table's date format (2026-03-10).

The fix was simple but important: strip the archive_ prefix before saving to the database, while keeping it in localStorage to maintain separate state between daily and archive games.

The Calendar UI

With the data issues fixed, I built a clean archive calendar that shows:

I intentionally removed the product name from the archive list. Showing the product name before playing would spoil the surprise and give players an unfair advantage in guessing the price.

Key Takeaways

When your data model stores multiple items per logical unit (date), every query that assumes one-to-one mapping will break. The fix is always at the data access layer, not the UI.

What's Next

With the archive working reliably, the next step is adding detailed statistics - streak tracking, archive completion percentage, and cross-player comparisons. If users want to see how they stack up against others on past challenges, the data is already there in the database. Stay tuned!

You can try the archive right now at price.etiel.dev/archive (requires a free Google login).