DIY Ticker

At work someone mentioned it would be cool to have a live ticker for new customers that sign up to our program.

In theory this is in my wheel house. I'm great at building shoddy next.js apps, I have a data source, and I can generally brute force my way through any hardware headaches— so let's make it happen.

To start I needed to pick out what this would run on. All I should really need was a screen, a single board computer, and a handful of right angled cables to keep things tidy.

For the panel itself I wanted something that resembled one of those LED stock tickers. Unfortunately LED panels are surprisingly expensive, so I had to settle for a LCD screen. I found a 12.3" panel for $60 on Amazon that fit the bill (and was way cheaper than anything I could find on Ali Express).

Screen

As for the brains of the operation, I grabbed a raspberry pi 5. Was this overkill for what would ultimately be an internet browser? Totally, but I plan to repurpose it and swap in cheaper hardware sooner or later.

Setup

Now, it's web app time.

Data-wise, there were a few constraints. The vendor we use to manage the program doesn’t have an API that supports any reporting requests to get live customer counts. The other contingency being that I wanted to avoid using any microservices, because I am only going to be pulling in a small amount of data.

In the name of keeping it "simple", it was easiest for me to create a Dagster job that runs a query on a local database to grab customer counts and commits the output in a json file directly to the repository every morning. I get more of those little green vanity contribution squares on my Github and I don’t have to worry about my Supabase project being paused abruptly, it’s a win-win.

import json
import base64
from datetime import datetime
import requests
from dagster import asset, EnvVar

@asset(required_resource_keys={"redshift"})
def store_enrollment_json(context):
    query = "SELECT" + """
    '{' || '"metadata": ' || metadata_json || ', ' || '"stores": ' || stores_json || '}'
    as json_output
    FROM (
       -- Get metadata as a JSON object. Need a max date for web component.
        SELECT '{"last_updated": "' || MAX(date) || '"}' as metadata_json
        FROM (
            -- Example data
            VALUES
                ('2024-01-01', 'Store A', 100),
                ('2024-01-02', 'Store A', 150),
                ('2024-01-01', 'Store B', 200),
                ('2024-01-02', 'Store B', 250)
        ) as sample_data(date, store, value)
    ) metadata,
    (
        -- Convert store data into nested JSON structure
        SELECT '{' || LISTAGG('"' || store || '": ' || dates_json, ', ')
        WITHIN GROUP (ORDER BY store) || '}'
        as stores_json
        FROM (
            -- For each store, create JSON object of dates and values
            SELECT 
                store,
                '{' || LISTAGG('"' || date || '": ' || value, ', ')
                WITHIN GROUP (ORDER BY date) || '}' as dates_json
            FROM (
                -- Example data
                VALUES
                    ('2024-01-01', 'Store A', 100),
                    ('2024-01-02', 'Store A', 150),
                    ('2024-01-01', 'Store B', 200),
                    ('2024-01-02', 'Store B', 250)
            ) as sample_data(date, store, value)
            GROUP BY store
        ) store_json
    ) stores_combined;
    """.strip()

    # Execute query and get results
    results = context.resources.redshift(query)
    json_data = results[0][0]  # Get the JSON string from the first row, first column

    # GitHub API configuration
    github_token = EnvVar("GITHUB_TOKEN").get_value()
    github_repo = EnvVar("GITHUB_REPO").get_value()
    github_api_url = f"https://api.github.com/repos/{github_repo}/contents/data.json"

    headers = {
        "Authorization": f"token {github_token}",
        "Accept": "application/vnd.github.v3+json"
    }

    # First, try to get the file if it exists to get its SHA
    response = requests.get(github_api_url, headers=headers)

    # Prepare the file content
    file_content = json.loads(json_data) 
    content_encoded = base64.b64encode(json.dumps(file_content, indent=2).encode()).decode()

    # Prepare the commit message
    commit_message = f"Update store enrollment data - {datetime.now().strftime('%Y-%m-%d')}"

    # Prepare the payload
    payload = {
        "message": commit_message,
        "content": content_encoded,
    }

    # If file exists, include its SHA in the payload
    if response.status_code == 200:
        payload["sha"] = response.json()["sha"]

    # Push to GitHub
    response = requests.put(github_api_url, headers=headers, json=payload)

    if response.status_code in [200, 201]:
        context.log.info("Successfully updated store enrollment data on GitHub")
    else:
        raise Exception(f"Failed to update file on GitHub: {response.status_code} - {response.text}")

# Update the assets list
website_assets = [
    store_enrollment_json
]

Now with the asset sorted, and a few other small configs not worth mentioning here, we can expect a json file with the example output below to be pushed to our repository every morning.

{
  "metadata": {"last_updated": "2024-01-02"},
  "stores": {
    "Store A": {
      "2024-01-01": 100,
      "2024-01-02": 150
    },
    "Store B": {
      "2024-01-01": 200,
      "2024-01-02": 250
    }
  }
}

A very important part of this project will be "seeing number go up". This is built with a lagged feed, so we will need to simulate customer growth based on the change from the previous day. Daily enrollment growth is pretty consistent, so there's no real need to go overboard by bringing in more data for better forecasting.

All that really matters for our component:

  • It always uses the most recent date in the data feed.
  • The count can only go up between 7AM - 10PM to emulate business hours.
  • The count goes up in time-dependent increments, so incase the Raspberry Pi is reset, the count will be consistent.
// Constants for business hours
const dayStartHour = 7;  // 7 AM
const dayEndHour = 22;   // 10 PM

// Calculate the current count based on time of day and historical data
const calculateCurrentCount = () => {
  const now = new Date();
  const currentHour = now.getHours();
  const currentMinute = now.getMinutes();

  // Get today's and yesterday's totals (either for all stores or selected store)
  let todayTotal, yesterdayTotal;
  if (selectedStore === 'All Stores') {
    todayTotal = Object.values(data.stores).reduce((sum, store) => sum + (store as StoreData)[mostRecentDate], 0);
    yesterdayTotal = Object.values(data.stores).reduce((sum, store) => sum + (store as StoreData)[previousDate], 0);
  } else {
    todayTotal = (data.stores[selectedStore] as StoreData)[mostRecentDate];
    yesterdayTotal = (data.stores[selectedStore] as StoreData)[previousDate];
  }

  // Calculate how many new customers we expect today
  const totalDailyIncrease = todayTotal - yesterdayTotal;

  // Calculate how far through the business day we are
  const totalMinutesInDay = (dayEndHour - dayStartHour) * 60;
  let minutesSinceStart = 0;
  if (currentHour >= dayStartHour && currentHour < dayEndHour) {
    minutesSinceStart = (currentHour - dayStartHour) * 60 + currentMinute;
  } else if (currentHour >= dayEndHour) {
    minutesSinceStart = totalMinutesInDay;
  }

  // Calculate progress through the day and expected increase
  const progress = Math.min(minutesSinceStart / totalMinutesInDay, 1);
  const expectedIncrease = Math.floor(totalDailyIncrease * progress);

  // Return the total expected count
  return todayTotal + expectedIncrease;
};

All we need to do now is write a function that checks if the count has gone up, and if so, update the count.

  // Calculate seconds until the next minute
  const now = new Date();
  const secondsUntilNextMinute = 60 - now.getSeconds();

  // Initial timeout to sync with the minute mark
  const initialTimeout = setTimeout(() => {
    // First update when we hit the next minute
    const newCount = calculateCurrentCount();
    setCount(newCount);
    setLastUpdate(new Date());

    // Then set up an interval for every minute after that
    const interval = setInterval(() => {
      const newCount = calculateCurrentCount();
      setCount(newCount);
      setLastUpdate(new Date());
    }, 60000); // Check every 60 seconds

    return () => clearInterval(interval);
  }, secondsUntilNextMinute * 1000);

  // Cleanup
  return () => clearTimeout(initialTimeout);
}, [selectedStore]);

Core functionality aside, seeing a number go up feels good, but do you know what feels even better?

Confetti cannons.

const triggerConfetti = () => {
  // Left corner confetti burst
  confetti({
    particleCount: 80,
    spread: 40,
    origin: { x: 0, y: 0.9 },
    angle: 45,
    startVelocity: 100,
    scalar: 1.4,
    gravity: 0.8,
    ticks: 400,
    colors: ['#cbdb09', '#1a6b1a', '#39ff14', '#FFFF00']
  });

  // Right corner confetti
  confetti({
    particleCount: 80,
    spread: 40,
    origin: { x: 1, y: 0.9 },
    angle: 135,
    startVelocity: 100,
    scalar: 1.4,
    gravity: 0.8,
    ticks: 400,
    colors: ['#cbdb09', '#1a6b1a', '#39ff14', '#FFFF00']
  });
};

  // Calculate seconds until the next minute
  const now = new Date();
  const secondsUntilNextMinute = 60 - now.getSeconds();

  // Initial timeout to sync with the minute mark
  const initialTimeout = setTimeout(() => {
    // First update when we hit the next minute
    const newCount = calculateCurrentCount();
    setCount(prevCount => {
      if (Math.floor(newCount) !== Math.floor(prevCount)) {
        setIsAnimating(true);
        triggerConfetti();
        setTimeout(() => setIsAnimating(false), 300);
      }
      return newCount;
    });
    setLastUpdate(new Date());

    // Then set up an interval for every minute after that
    const interval = setInterval(() => {
      const newCount = calculateCurrentCount();
      setCount(prevCount => {
        if (Math.floor(newCount) !== Math.floor(prevCount)) {
          setIsAnimating(true);
          triggerConfetti();
          setTimeout(() => setIsAnimating(false), 300);
        }
        return newCount;
      });
      setLastUpdate(new Date());
    }, 60000); // Check every 60 seconds

    return () => clearInterval(interval);
  }, secondsUntilNextMinute * 1000);

  // Cleanup
  return () => clearTimeout(initialTimeout);
}, [selectedStore]);

Now we are TALKING.

DIY

With the web app wrapped up, all we need to do is configure the pi to boot up directly to this page.

To do this we'll need to create a new script that will launch Chromium in kiosk mode:

sudo nano /usr/bin/kiosk.sh

Then add these fields to the existing file:

#!/bin/bash
xset s off
xset s noblank
xset -dpms

unclutter -idle 0 -root &

sed -i 's/"exited_cleanly":false/"exited_cleanly":true/' ~/.config/chromium/Default/Preferences
sed -i 's/"exit_type":"Crashed"/"exit_type":"Normal"/' ~/.config/chromium/Default/Preferences

/usr/bin/chromium-browser --noerrdialogs --disable-infobars --kiosk https://neighbor-rewards.vercel.app/

Make it executable:

sudo chmod +x /usr/bin/kiosk.sh

Create an autostart directory:

mkdir -p ~/.config/autostart

Create a .desktop file to run the script on startup:

nano ~/.config/autostart/kiosk.desktop

And then add this to the desktop file:

[Desktop Entry]
Type=Application
Name=Kiosk
Exec=/usr/bin/kiosk.sh
X-GNOME-Autostart-enabled=true

Now on boot, the pi should go directly to the web app!

Ticker