# Feed Algorithm Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Replace the purely chronological feed with a configurable scored algorithm that personalizes content based on relationships, profile affinity, engagement, and recency — with a full admin UI to switch algorithms and tune scoring weights in real time.

**Architecture:** The feed endpoint (`GET /api/media/feed`) checks `app_settings` for the active algorithm. When `feed_algorithm = 'scored'`, it computes a composite score for each post using 4 factors (relationship, affinity, engagement, recency) with admin-tunable weights. A new Perl admin page provides the configuration UI. When `feed_algorithm = 'chronological'`, the current behavior is preserved unchanged.

**Tech Stack:** Node.js/Express (API), MySQL (scoring in SQL), Perl CGI + Tailwind CSS (admin UI), React Native (no changes needed — client renders whatever the API returns)

---

### Task 1: Seed Default Feed Settings in app_settings

**Files:**
- Modify: `hbcu-app/api/routes/media.js` (no changes yet — just DB setup)

**Context:** The `app_settings` table already exists in `hbcu_central` (used by content moderation). We need to insert default feed algorithm settings. The table has columns: `setting_key` (PK), `setting_value`, `updated_by`, plus auto timestamps.

**Step 1: Insert default settings via MySQL**

Run against `hbcu_central` on `newdb.hbcuconnect.com`:

```sql
INSERT INTO app_settings (setting_key, setting_value, updated_by) VALUES
  ('feed_algorithm', 'scored', 'system'),
  ('feed_weight_relationship', '1.0', 'system'),
  ('feed_weight_affinity', '1.0', 'system'),
  ('feed_weight_engagement', '1.0', 'system'),
  ('feed_weight_recency', '1.0', 'system'),
  ('feed_relationship_friend', '40', 'system'),
  ('feed_relationship_following', '25', 'system'),
  ('feed_relationship_follower', '15', 'system'),
  ('feed_relationship_same_school', '10', 'system'),
  ('feed_relationship_same_state', '5', 'system'),
  ('feed_affinity_same_school', '15', 'system'),
  ('feed_affinity_same_major', '8', 'system'),
  ('feed_affinity_same_state', '5', 'system'),
  ('feed_affinity_same_city', '5', 'system'),
  ('feed_affinity_same_org', '5', 'system'),
  ('feed_affinity_cap', '30', 'system'),
  ('feed_engagement_comment_weight', '3', 'system'),
  ('feed_engagement_like_weight', '1', 'system'),
  ('feed_engagement_view_weight', '0.1', 'system'),
  ('feed_engagement_cap', '20', 'system'),
  ('feed_recency_max_score', '30', 'system'),
  ('feed_recency_halflife_hours', '72', 'system'),
  ('feed_jitter_max', '5', 'system')
ON DUPLICATE KEY UPDATE setting_key = setting_key;
```

**Step 2: Verify settings inserted**

```sql
SELECT setting_key, setting_value FROM app_settings WHERE setting_key LIKE 'feed_%' ORDER BY setting_key;
```

Expected: 23 rows with all `feed_*` keys.

---

### Task 2: Build the Scored Feed Query in media.js

**Files:**
- Modify: `hbcu-app/api/routes/media.js:183-301` (the `GET /feed` route)

**Context:** The current feed query at line 203-221 is `ORDER BY m.created_at DESC`. We need to:
1. Load feed settings from `app_settings` on each request (cached with a 60-second TTL for performance)
2. If algorithm is `chronological`, keep current behavior unchanged
3. If algorithm is `scored`, replace with a scoring query

**Step 1: Add settings cache at the top of media.js (after line 11, before the routes)**

Add this after the existing imports/requires (around line 11):

```javascript
// Feed algorithm settings cache (60-second TTL)
let feedSettingsCache = null;
let feedSettingsCacheTime = 0;
const FEED_SETTINGS_TTL = 60000; // 60 seconds

async function getFeedSettings() {
  const now = Date.now();
  if (feedSettingsCache && (now - feedSettingsCacheTime) < FEED_SETTINGS_TTL) {
    return feedSettingsCache;
  }
  const [rows] = await hbcuPool.query(
    "SELECT setting_key, setting_value FROM app_settings WHERE setting_key LIKE 'feed_%'"
  );
  const settings = {};
  for (const row of rows) {
    settings[row.setting_key] = row.setting_value;
  }
  feedSettingsCache = settings;
  feedSettingsCacheTime = now;
  return settings;
}
```

**Step 2: Replace the feed route handler**

Replace the entire `router.get('/feed', ...)` handler (lines 183-301) with this implementation. The key change is the scored query — it uses LEFT JOINs to compute relationship and affinity scores, inline SQL math for engagement and recency, then orders by `total_score DESC`:

```javascript
router.get('/feed', async (req, res) => {
  try {
    const page = Math.max(1, parseInt(req.query.page, 10) || 1);
    const perPage = 20;
    const offset = (page - 1) * perPage;

    // Load feed settings
    const fs = await getFeedSettings();
    const algorithm = fs.feed_algorithm || 'scored';

    // Get blocked user IDs
    const [blockedRows] = await mediaPool.execute(
      `SELECT blockee AS id FROM registry_block WHERE blocker = ?
       UNION SELECT blocker AS id FROM registry_block WHERE blockee = ?`,
      [req.userId, req.userId]
    );
    const blockedIds = blockedRows.map(r => r.id);
    const blockClause = blockedIds.length > 0
      ? `AND m.registry_id NOT IN (${blockedIds.join(',')})`
      : '';

    const vis = visibilityClause(req.userId);

    let rows, countRows;

    if (algorithm === 'chronological') {
      // === CHRONOLOGICAL MODE (original behavior) ===
      [rows] = await mediaPool.query(
        `SELECT m.*, r.first_name, r.last_name, r.photo_link,
                EXISTS(SELECT 1 FROM app_media_likes l WHERE l.media_id = m.media_id AND l.registry_id = ?) AS liked_by_me,
                EXISTS(SELECT 1 FROM app_follows f WHERE f.follower_id = ? AND f.following_id = m.registry_id) AS is_following,
                IF(r.last_visit >= DATE_SUB(NOW(), INTERVAL 15 MINUTE)
                  AND (rp_live.hide_online_status IS NULL OR rp_live.hide_online_status != 'yes'), 1, 0) AS is_live
         FROM app_media m
         INNER JOIN registry_data r ON r.registry_id = m.registry_id
         LEFT JOIN registry_data_prefs rp_live ON rp_live.registry_id = m.registry_id
         WHERE m.active = 1
           AND (m.moderation_status = 'approved' OR m.moderation_status IS NULL
                OR (m.moderation_status = 'review' AND m.registry_id = ?))
           AND r.spammer IN (0, 2)
           ${blockClause}
           ${vis.sql}
         ORDER BY m.created_at DESC
         LIMIT ${perPage} OFFSET ${offset}`,
        [req.userId, req.userId, req.userId, ...vis.params]
      );

      [countRows] = await mediaPool.query(
        `SELECT COUNT(*) AS total
         FROM app_media m
         INNER JOIN registry_data r ON r.registry_id = m.registry_id
         WHERE m.active = 1
           AND (m.moderation_status = 'approved' OR m.moderation_status IS NULL
                OR (m.moderation_status = 'review' AND m.registry_id = ?))
           AND r.spammer IN (0, 2) ${blockClause}
           ${vis.sql}`,
        [req.userId, ...vis.params]
      );

    } else {
      // === SCORED MODE ===
      // Parse scoring parameters from settings
      const wRel = parseFloat(fs.feed_weight_relationship) || 1.0;
      const wAff = parseFloat(fs.feed_weight_affinity) || 1.0;
      const wEng = parseFloat(fs.feed_weight_engagement) || 1.0;
      const wRec = parseFloat(fs.feed_weight_recency) || 1.0;

      const relFriend     = parseInt(fs.feed_relationship_friend) || 40;
      const relFollowing  = parseInt(fs.feed_relationship_following) || 25;
      const relFollower   = parseInt(fs.feed_relationship_follower) || 15;
      const relSameSchool = parseInt(fs.feed_relationship_same_school) || 10;
      const relSameState  = parseInt(fs.feed_relationship_same_state) || 5;

      const affSchool = parseInt(fs.feed_affinity_same_school) || 15;
      const affMajor  = parseInt(fs.feed_affinity_same_major) || 8;
      const affState  = parseInt(fs.feed_affinity_same_state) || 5;
      const affCity   = parseInt(fs.feed_affinity_same_city) || 5;
      const affOrg    = parseInt(fs.feed_affinity_same_org) || 5;
      const affCap    = parseInt(fs.feed_affinity_cap) || 30;

      const engComment = parseFloat(fs.feed_engagement_comment_weight) || 3;
      const engLike    = parseFloat(fs.feed_engagement_like_weight) || 1;
      const engView    = parseFloat(fs.feed_engagement_view_weight) || 0.1;
      const engCap     = parseInt(fs.feed_engagement_cap) || 20;

      const recMax      = parseInt(fs.feed_recency_max_score) || 30;
      const recHalflife = parseInt(fs.feed_recency_halflife_hours) || 72;
      const jitterMax   = parseInt(fs.feed_jitter_max) || 5;

      // Get current user's profile for affinity matching
      const [userRows] = await hbcuPool.execute(
        `SELECT hbcu, major, state, city, organization FROM registry_data WHERE registry_id = ?`,
        [req.userId]
      );
      const me = userRows[0] || {};

      // Build affinity conditions
      const affinityParts = [];
      const affinityParams = [];
      if (me.hbcu) {
        affinityParts.push(`IF(r.hbcu = ?, ${affSchool}, 0)`);
        affinityParams.push(me.hbcu);
      }
      if (me.major && me.major.trim()) {
        affinityParts.push(`IF(r.major = ?, ${affMajor}, 0)`);
        affinityParams.push(me.major);
      }
      if (me.state && me.state.trim()) {
        affinityParts.push(`IF(r.state = ?, ${affState}, 0)`);
        affinityParams.push(me.state);
      }
      if (me.city && me.city.trim()) {
        affinityParts.push(`IF(r.city = ?, ${affCity}, 0)`);
        affinityParams.push(me.city);
      }
      if (me.organization && me.organization.trim()) {
        affinityParts.push(`IF(r.organization = ?, ${affOrg}, 0)`);
        affinityParams.push(me.organization);
      }

      const affinityExpr = affinityParts.length > 0
        ? `LEAST(${affinityParts.join(' + ')}, ${affCap})`
        : '0';

      // The scored query
      [rows] = await mediaPool.query(
        `SELECT m.*, r.first_name, r.last_name, r.photo_link,
                EXISTS(SELECT 1 FROM app_media_likes l WHERE l.media_id = m.media_id AND l.registry_id = ?) AS liked_by_me,
                EXISTS(SELECT 1 FROM app_follows f WHERE f.follower_id = ? AND f.following_id = m.registry_id) AS is_following,
                IF(r.last_visit >= DATE_SUB(NOW(), INTERVAL 15 MINUTE)
                  AND (rp_live.hide_online_status IS NULL OR rp_live.hide_online_status != 'yes'), 1, 0) AS is_live,

                /* --- RELATIONSHIP SCORE --- */
                (CASE
                  WHEN EXISTS(SELECT 1 FROM friends_list
                       WHERE confirm_date IS NOT NULL
                         AND ((invite_from_id = ? AND invite_to_id = m.registry_id)
                           OR (invite_to_id = ? AND invite_from_id = m.registry_id)))
                  THEN ${relFriend}
                  WHEN EXISTS(SELECT 1 FROM app_follows WHERE follower_id = ? AND following_id = m.registry_id)
                  THEN ${relFollowing}
                  WHEN EXISTS(SELECT 1 FROM app_follows WHERE follower_id = m.registry_id AND following_id = ?)
                  THEN ${relFollower}
                  ${me.hbcu ? `WHEN r.hbcu = ? THEN ${relSameSchool}` : ''}
                  ${me.state ? `WHEN r.state = ? THEN ${relSameState}` : ''}
                  ELSE 0
                END) AS relationship_score,

                /* --- AFFINITY SCORE --- */
                (${affinityExpr}) AS affinity_score,

                /* --- ENGAGEMENT SCORE --- */
                LEAST(${engCap}, LOG2(1 + m.comments_count * ${engComment} + m.likes_count * ${engLike} + IFNULL(m.views_count, 0) * ${engView}) * 3) AS engagement_score,

                /* --- RECENCY SCORE --- */
                ${recMax} * POW(0.5, TIMESTAMPDIFF(SECOND, m.created_at, NOW()) / (${recHalflife} * 3600)) AS recency_score,

                /* --- TOTAL SCORE --- */
                (
                  (CASE
                    WHEN EXISTS(SELECT 1 FROM friends_list
                         WHERE confirm_date IS NOT NULL
                           AND ((invite_from_id = ? AND invite_to_id = m.registry_id)
                             OR (invite_to_id = ? AND invite_from_id = m.registry_id)))
                    THEN ${relFriend}
                    WHEN EXISTS(SELECT 1 FROM app_follows WHERE follower_id = ? AND following_id = m.registry_id)
                    THEN ${relFollowing}
                    WHEN EXISTS(SELECT 1 FROM app_follows WHERE follower_id = m.registry_id AND following_id = ?)
                    THEN ${relFollower}
                    ${me.hbcu ? `WHEN r.hbcu = ? THEN ${relSameSchool}` : ''}
                    ${me.state ? `WHEN r.state = ? THEN ${relSameState}` : ''}
                    ELSE 0
                  END) * ${wRel}
                  + (${affinityExpr}) * ${wAff}
                  + LEAST(${engCap}, LOG2(1 + m.comments_count * ${engComment} + m.likes_count * ${engLike} + IFNULL(m.views_count, 0) * ${engView}) * 3) * ${wEng}
                  + ${recMax} * POW(0.5, TIMESTAMPDIFF(SECOND, m.created_at, NOW()) / (${recHalflife} * 3600)) * ${wRec}
                  + RAND() * ${jitterMax}
                ) AS total_score

         FROM app_media m
         INNER JOIN registry_data r ON r.registry_id = m.registry_id
         LEFT JOIN registry_data_prefs rp_live ON rp_live.registry_id = m.registry_id
         WHERE m.active = 1
           AND (m.moderation_status = 'approved' OR m.moderation_status IS NULL
                OR (m.moderation_status = 'review' AND m.registry_id = ?))
           AND r.spammer IN (0, 2)
           ${blockClause}
           ${vis.sql}
         ORDER BY total_score DESC
         LIMIT ${perPage} OFFSET ${offset}`,
        [
          req.userId, req.userId,                              // liked_by_me, is_following
          req.userId, req.userId, req.userId, req.userId,      // relationship CASE (friend check x2, following, follower)
          ...(me.hbcu ? [me.hbcu] : []),                       // relationship same school
          ...(me.state ? [me.state] : []),                     // relationship same state
          ...affinityParams,                                   // affinity conditions
          req.userId, req.userId, req.userId, req.userId,      // total_score relationship CASE (duplicated)
          ...(me.hbcu ? [me.hbcu] : []),                       // total_score same school
          ...(me.state ? [me.state] : []),                     // total_score same state
          ...affinityParams,                                   // total_score affinity conditions
          req.userId,                                          // moderation self-view
          ...vis.params                                        // visibility clause
        ]
      );

      // Count query stays the same (total doesn't change with scoring)
      [countRows] = await mediaPool.query(
        `SELECT COUNT(*) AS total
         FROM app_media m
         INNER JOIN registry_data r ON r.registry_id = m.registry_id
         WHERE m.active = 1
           AND (m.moderation_status = 'approved' OR m.moderation_status IS NULL
                OR (m.moderation_status = 'review' AND m.registry_id = ?))
           AND r.spammer IN (0, 2) ${blockClause}
           ${vis.sql}`,
        [req.userId, ...vis.params]
      );
    }

    const total = countRows[0].total;

    // Batch-fetch friend/followed likers for all posts in this page
    const mediaIds = rows.map(r => r.media_id).filter(id => id);
    const likersByMedia = {};
    if (mediaIds.length > 0) {
      const placeholders = mediaIds.map(() => '?').join(',');
      const [likerRows] = await mediaPool.query(
        `SELECT lk.media_id, r.first_name, r.last_name
         FROM app_media_likes lk
         INNER JOIN registry_data r ON r.registry_id = lk.registry_id
         WHERE lk.media_id IN (${placeholders})
           AND lk.registry_id != ?
           AND (
             EXISTS(SELECT 1 FROM friends_list f
                    WHERE f.confirm_date IS NOT NULL
                      AND ((f.invite_from_id = ? AND f.invite_to_id = lk.registry_id)
                        OR (f.invite_to_id = ? AND f.invite_from_id = lk.registry_id)))
             OR EXISTS(SELECT 1 FROM app_follows af
                       WHERE af.follower_id = ? AND af.following_id = lk.registry_id)
           )
         ORDER BY lk.media_id`,
        [...mediaIds, req.userId, req.userId, req.userId, req.userId]
      );
      for (const lr of likerRows) {
        if (!likersByMedia[lr.media_id]) likersByMedia[lr.media_id] = [];
        if (likersByMedia[lr.media_id].length < 2) {
          likersByMedia[lr.media_id].push({
            first_name: lr.first_name,
            last_initial: lr.last_name ? lr.last_name.charAt(0) + '.' : '',
          });
        }
      }
    }

    const posts = rows.map(r => ({
      media_id: r.media_id,
      media_type: r.media_type,
      caption: r.caption,
      visibility: r.visibility,
      width: r.width,
      height: r.height,
      duration: r.duration,
      likes_count: r.likes_count,
      comments_count: r.comments_count,
      views_count: r.views_count || 0,
      moderation_status: r.moderation_status || 'approved',
      created_at: r.created_at,
      liked_by_me: r.liked_by_me === 1,
      is_following: r.is_following === 1,
      liked_by_known: likersByMedia[r.media_id] || [],
      ...buildMediaUrls(r),
      user: {
        registry_id: r.registry_id,
        first_name: r.first_name,
        last_name: r.last_name,
        photo_url: buildPhotoUrl(r.photo_link, r.registry_id),
        is_live: r.is_live === 1,
      },
    }));

    res.json({ posts, total, page, pages: Math.ceil(total / perPage) });
  } catch (err) {
    console.error('Feed error:', err);
    res.status(500).json({ error: 'Internal server error' });
  }
});
```

**Important notes on the scored query:**
- The relationship CASE is duplicated in the SELECT (once for the individual score column, once for total_score) because MySQL doesn't allow referencing column aliases in the same SELECT. The params must be duplicated correspondingly.
- `affinityParams` are also duplicated for the same reason.
- `RAND() * jitterMax` adds slight randomness so the feed isn't identical on every refresh.
- `LOG2(1 + ...)` provides logarithmic scaling so posts with 1000 likes don't get 1000x the score of posts with 1 like.
- `POW(0.5, seconds / halflife_seconds)` gives exponential decay with the configured half-life.

**Step 3: Verify syntax and restart**

```bash
cd /var/www/vhosts/hbcuconnect.com/httpdocs/hbcu-app/api
node -e "require('./routes/media')"   # Basic syntax check
pm2 restart hbcu-api
```

Check logs for startup errors:
```bash
tail -20 /var/log/hbcu-app/out-0.log
```

---

### Task 3: Create the Admin Feed Settings Page

**Files:**
- Create: `admin/app/feed_settings.cgi`

**Context:** This page follows the same pattern as `admin/app/content_moderation.cgi` — Perl CGI with Tailwind CSS via `adminUI.pm`. It provides:
1. Algorithm selector (Chronological vs Scored) as toggle cards
2. Scoring weight sliders/inputs for all 4 factor groups
3. Individual point value inputs for each sub-factor
4. "Reset to Defaults" button
5. All changes saved to `app_settings` via AJAX

**Step 1: Create the admin page**

Create `admin/app/feed_settings.cgi` with this structure:

```perl
#!/usr/bin/perl
######################################################################
#### Feed Algorithm Settings — Admin Tool
#### Configure feed algorithm and scoring weights
######################################################################
if ($ENV{QUERY_STRING} =~ /xor|SELECT|SLEEP|INFORMATION_SCHEMA|UNION/i) {
    print "Status: 403 Forbidden\nContent-Type: text/html\n\n"; exit;
}

use strict;
use warnings;
use CGI;
use DBI;
use JSON;

#### INCLUDE FILES ####
require "../commonFuncs.pm";
require "../../cgi-bin/lib/commonDB.pl";
require "../../cgi-bin/lib/hbcuAuth.pl";
require "../adminUI.pm";
import adminUI qw(printAdminHeader printAdminNav printAdminFooter);

#### SET VARIABLES ####
my $q    = new CGI;
my $dbh  = &getDBHandle("hbcu_central");
my $USER = $ENV{'REMOTE_USER'} || 'guest';

################################ MAIN PROGRAM ################################

if (my $action = $q->param('action')) {
    if    ($action eq 'get_settings')    { getSettings(); }
    elsif ($action eq 'save_settings')   { saveSettings(); }
    elsif ($action eq 'reset_defaults')  { resetDefaults(); }
    else { jsonResponse(400, { error => 'Unknown action' }); }
} else {
    printPage();
}

################################ AJAX ACTIONS ################################

sub getSettings {
    my %settings;
    eval {
        my $sth = $dbh->prepare(
            "SELECT setting_key, setting_value FROM app_settings WHERE setting_key LIKE 'feed_%'"
        );
        $sth->execute();
        while (my $row = $sth->fetchrow_hashref) {
            $settings{$row->{setting_key}} = $row->{setting_value};
        }
    };
    if ($@) {
        return jsonResponse(500, { error => "Database error: $@" });
    }
    jsonResponse(200, { settings => \%settings });
}

sub saveSettings {
    my $data = $q->param('settings');
    my $settings;
    eval { $settings = decode_json($data); };
    if ($@ || ref($settings) ne 'HASH') {
        return jsonResponse(400, { error => 'Invalid JSON' });
    }

    eval {
        my $sth = $dbh->prepare(
            "INSERT INTO app_settings (setting_key, setting_value, updated_by)
             VALUES (?, ?, ?)
             ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_by = VALUES(updated_by)"
        );
        for my $key (keys %$settings) {
            next unless $key =~ /^feed_/;  # Only allow feed_* keys
            $sth->execute($key, $settings->{$key}, $USER);
        }
    };
    if ($@) {
        return jsonResponse(500, { error => "Save failed: $@" });
    }
    jsonResponse(200, { success => 1 });
}

sub resetDefaults {
    my %defaults = getDefaults();
    eval {
        my $sth = $dbh->prepare(
            "INSERT INTO app_settings (setting_key, setting_value, updated_by)
             VALUES (?, ?, ?)
             ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_by = VALUES(updated_by)"
        );
        for my $key (keys %defaults) {
            $sth->execute($key, $defaults{$key}, $USER);
        }
    };
    if ($@) {
        return jsonResponse(500, { error => "Reset failed: $@" });
    }
    jsonResponse(200, { success => 1, settings => \%defaults });
}

sub getDefaults {
    return (
        feed_algorithm               => 'scored',
        feed_weight_relationship     => '1.0',
        feed_weight_affinity         => '1.0',
        feed_weight_engagement       => '1.0',
        feed_weight_recency          => '1.0',
        feed_relationship_friend     => '40',
        feed_relationship_following  => '25',
        feed_relationship_follower   => '15',
        feed_relationship_same_school => '10',
        feed_relationship_same_state => '5',
        feed_affinity_same_school    => '15',
        feed_affinity_same_major     => '8',
        feed_affinity_same_state     => '5',
        feed_affinity_same_city      => '5',
        feed_affinity_same_org       => '5',
        feed_affinity_cap            => '30',
        feed_engagement_comment_weight => '3',
        feed_engagement_like_weight  => '1',
        feed_engagement_view_weight  => '0.1',
        feed_engagement_cap          => '20',
        feed_recency_max_score       => '30',
        feed_recency_halflife_hours  => '72',
        feed_jitter_max              => '5',
    );
}

sub jsonResponse {
    my ($code, $data) = @_;
    print $q->header(-type => 'application/json', -status => $code);
    print encode_json($data);
    exit;
}

################################ PAGE ################################

sub printPage {
    print $q->header('text/html');

    my $page_css = qq~
        .algo-card { transition: all 0.2s ease; cursor: pointer; }
        .algo-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
        .algo-card.selected { ring: 2px; }
        .weight-slider { -webkit-appearance: none; appearance: none; height: 8px; border-radius: 4px; background: #e5e7eb; outline: none; }
        .weight-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 20px; height: 20px; border-radius: 50%; background: #993300; cursor: pointer; }
        .weight-slider::-moz-range-thumb { width: 20px; height: 20px; border-radius: 50%; background: #993300; cursor: pointer; border: none; }
        .factor-group { transition: opacity 0.3s ease; }
        .factor-group.disabled { opacity: 0.4; pointer-events: none; }
        .score-input { width: 70px; }
        .save-toast { transform: translateY(100%); opacity: 0; transition: all 0.3s ease; }
        .save-toast.show { transform: translateY(0); opacity: 1; }
    ~;

    printAdminHeader(
        title      => 'Feed Algorithm Settings',
        user_name  => $USER,
        page_title => 'Feed Algorithm | HBCU Connect',
        extra_css  => $page_css,
    );
    printAdminNav(active => '/admin/app/feed_settings.cgi');

    print qq~
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">

    <!-- Page Header -->
    <div class="flex items-center justify-between mb-6">
        <div>
            <h1 class="text-2xl font-bold text-gray-800">Feed Algorithm Settings</h1>
            <p class="text-gray-500 text-sm mt-1">Configure how the mobile app feed ranks and orders posts</p>
        </div>
        <div class="flex gap-3">
            <button onclick="resetToDefaults()" class="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg text-sm font-medium transition">
                <svg class="inline w-4 h-4 mr-1 -mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
                Reset to Defaults
            </button>
            <button onclick="saveAllSettings()" id="saveBtn" class="px-5 py-2 bg-hbcu-700 hover:bg-hbcu-800 text-white rounded-lg text-sm font-medium transition shadow-sm">
                <svg class="inline w-4 h-4 mr-1 -mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
                Save Changes
            </button>
        </div>
    </div>

    <!-- Algorithm Selector -->
    <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
        <h2 class="text-lg font-semibold text-gray-800 mb-1">Algorithm Mode</h2>
        <p class="text-sm text-gray-500 mb-4">Choose how the feed orders posts for all users</p>
        <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
            <div id="card-scored" onclick="selectAlgorithm('scored')" class="algo-card border-2 rounded-xl p-5">
                <div class="flex items-center gap-3 mb-2">
                    <div class="bg-hbcu-100 text-hbcu-700 p-2 rounded-lg">
                        <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
                    </div>
                    <div>
                        <h3 class="font-semibold text-gray-800">Smart Scoring</h3>
                        <p class="text-xs text-gray-500">Personalized feed based on relationships, interests, and engagement</p>
                    </div>
                </div>
            </div>
            <div id="card-chronological" onclick="selectAlgorithm('chronological')" class="algo-card border-2 rounded-xl p-5">
                <div class="flex items-center gap-3 mb-2">
                    <div class="bg-gray-100 text-gray-600 p-2 rounded-lg">
                        <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
                    </div>
                    <div>
                        <h3 class="font-semibold text-gray-800">Chronological</h3>
                        <p class="text-xs text-gray-500">Simple reverse-chronological order (newest first)</p>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <!-- Scoring Weights (master multipliers) -->
    <div id="scoring-section" class="space-y-6">

        <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
            <h2 class="text-lg font-semibold text-gray-800 mb-1">Factor Weights</h2>
            <p class="text-sm text-gray-500 mb-4">Master multipliers for each scoring category. Set to 0 to disable a factor entirely.</p>
            <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
                <div>
                    <label class="block text-sm font-medium text-gray-700 mb-2">Relationship <span id="wRelVal" class="text-hbcu-700 font-bold">1.0</span>x</label>
                    <input type="range" min="0" max="3" step="0.1" value="1.0" class="weight-slider w-full" data-key="feed_weight_relationship" data-display="wRelVal" oninput="updateSlider(this)">
                </div>
                <div>
                    <label class="block text-sm font-medium text-gray-700 mb-2">Affinity <span id="wAffVal" class="text-hbcu-700 font-bold">1.0</span>x</label>
                    <input type="range" min="0" max="3" step="0.1" value="1.0" class="weight-slider w-full" data-key="feed_weight_affinity" data-display="wAffVal" oninput="updateSlider(this)">
                </div>
                <div>
                    <label class="block text-sm font-medium text-gray-700 mb-2">Engagement <span id="wEngVal" class="text-hbcu-700 font-bold">1.0</span>x</label>
                    <input type="range" min="0" max="3" step="0.1" value="1.0" class="weight-slider w-full" data-key="feed_weight_engagement" data-display="wEngVal" oninput="updateSlider(this)">
                </div>
                <div>
                    <label class="block text-sm font-medium text-gray-700 mb-2">Recency <span id="wRecVal" class="text-hbcu-700 font-bold">1.0</span>x</label>
                    <input type="range" min="0" max="3" step="0.1" value="1.0" class="weight-slider w-full" data-key="feed_weight_recency" data-display="wRecVal" oninput="updateSlider(this)">
                </div>
            </div>
        </div>

        <!-- Relationship Scoring -->
        <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
            <div class="flex items-center gap-2 mb-1">
                <svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
                <h2 class="text-lg font-semibold text-gray-800">Relationship Scoring</h2>
            </div>
            <p class="text-sm text-gray-500 mb-4">Points awarded based on the viewer's relationship to the post author (best match wins)</p>
            <div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
                <div class="text-center">
                    <label class="block text-xs text-gray-500 mb-1">Friend</label>
                    <input type="number" min="0" max="100" value="40" class="score-input mx-auto block border border-gray-300 rounded-lg px-3 py-2 text-center text-sm font-medium focus:outline-none focus:border-hbcu-700" data-key="feed_relationship_friend">
                    <div class="text-xs text-gray-400 mt-1">Default: 40</div>
                </div>
                <div class="text-center">
                    <label class="block text-xs text-gray-500 mb-1">Following</label>
                    <input type="number" min="0" max="100" value="25" class="score-input mx-auto block border border-gray-300 rounded-lg px-3 py-2 text-center text-sm font-medium focus:outline-none focus:border-hbcu-700" data-key="feed_relationship_following">
                    <div class="text-xs text-gray-400 mt-1">Default: 25</div>
                </div>
                <div class="text-center">
                    <label class="block text-xs text-gray-500 mb-1">Follower</label>
                    <input type="number" min="0" max="100" value="15" class="score-input mx-auto block border border-gray-300 rounded-lg px-3 py-2 text-center text-sm font-medium focus:outline-none focus:border-hbcu-700" data-key="feed_relationship_follower">
                    <div class="text-xs text-gray-400 mt-1">Default: 15</div>
                </div>
                <div class="text-center">
                    <label class="block text-xs text-gray-500 mb-1">Same School</label>
                    <input type="number" min="0" max="100" value="10" class="score-input mx-auto block border border-gray-300 rounded-lg px-3 py-2 text-center text-sm font-medium focus:outline-none focus:border-hbcu-700" data-key="feed_relationship_same_school">
                    <div class="text-xs text-gray-400 mt-1">Default: 10</div>
                </div>
                <div class="text-center">
                    <label class="block text-xs text-gray-500 mb-1">Same State</label>
                    <input type="number" min="0" max="100" value="5" class="score-input mx-auto block border border-gray-300 rounded-lg px-3 py-2 text-center text-sm font-medium focus:outline-none focus:border-hbcu-700" data-key="feed_relationship_same_state">
                    <div class="text-xs text-gray-400 mt-1">Default: 5</div>
                </div>
            </div>
        </div>

        <!-- Affinity Scoring -->
        <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
            <div class="flex items-center gap-2 mb-1">
                <svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>
                <h2 class="text-lg font-semibold text-gray-800">Affinity Scoring</h2>
            </div>
            <p class="text-sm text-gray-500 mb-4">Points awarded when the post author shares profile attributes with the viewer (additive, capped)</p>
            <div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
                <div class="text-center">
                    <label class="block text-xs text-gray-500 mb-1">Same School</label>
                    <input type="number" min="0" max="50" value="15" class="score-input mx-auto block border border-gray-300 rounded-lg px-3 py-2 text-center text-sm font-medium focus:outline-none focus:border-hbcu-700" data-key="feed_affinity_same_school">
                    <div class="text-xs text-gray-400 mt-1">Default: 15</div>
                </div>
                <div class="text-center">
                    <label class="block text-xs text-gray-500 mb-1">Same Major</label>
                    <input type="number" min="0" max="50" value="8" class="score-input mx-auto block border border-gray-300 rounded-lg px-3 py-2 text-center text-sm font-medium focus:outline-none focus:border-hbcu-700" data-key="feed_affinity_same_major">
                    <div class="text-xs text-gray-400 mt-1">Default: 8</div>
                </div>
                <div class="text-center">
                    <label class="block text-xs text-gray-500 mb-1">Same State</label>
                    <input type="number" min="0" max="50" value="5" class="score-input mx-auto block border border-gray-300 rounded-lg px-3 py-2 text-center text-sm font-medium focus:outline-none focus:border-hbcu-700" data-key="feed_affinity_same_state">
                    <div class="text-xs text-gray-400 mt-1">Default: 5</div>
                </div>
                <div class="text-center">
                    <label class="block text-xs text-gray-500 mb-1">Same City</label>
                    <input type="number" min="0" max="50" value="5" class="score-input mx-auto block border border-gray-300 rounded-lg px-3 py-2 text-center text-sm font-medium focus:outline-none focus:border-hbcu-700" data-key="feed_affinity_same_city">
                    <div class="text-xs text-gray-400 mt-1">Default: 5</div>
                </div>
                <div class="text-center">
                    <label class="block text-xs text-gray-500 mb-1">Same Org</label>
                    <input type="number" min="0" max="50" value="5" class="score-input mx-auto block border border-gray-300 rounded-lg px-3 py-2 text-center text-sm font-medium focus:outline-none focus:border-hbcu-700" data-key="feed_affinity_same_org">
                    <div class="text-xs text-gray-400 mt-1">Default: 5</div>
                </div>
                <div class="text-center">
                    <label class="block text-xs text-gray-500 mb-1">Cap</label>
                    <input type="number" min="0" max="100" value="30" class="score-input mx-auto block border border-gray-300 rounded-lg px-3 py-2 text-center text-sm font-medium focus:outline-none focus:border-hbcu-700 bg-gray-50" data-key="feed_affinity_cap">
                    <div class="text-xs text-gray-400 mt-1">Default: 30</div>
                </div>
            </div>
        </div>

        <!-- Engagement Scoring -->
        <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
            <div class="flex items-center gap-2 mb-1">
                <svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
                <h2 class="text-lg font-semibold text-gray-800">Engagement Scoring</h2>
            </div>
            <p class="text-sm text-gray-500 mb-4">Weighted engagement: <code>LOG2(1 + comments*w + likes*w + views*w) * 3</code>, capped at max</p>
            <div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
                <div class="text-center">
                    <label class="block text-xs text-gray-500 mb-1">Comment Weight</label>
                    <input type="number" min="0" max="20" step="0.5" value="3" class="score-input mx-auto block border border-gray-300 rounded-lg px-3 py-2 text-center text-sm font-medium focus:outline-none focus:border-hbcu-700" data-key="feed_engagement_comment_weight">
                    <div class="text-xs text-gray-400 mt-1">Default: 3</div>
                </div>
                <div class="text-center">
                    <label class="block text-xs text-gray-500 mb-1">Like Weight</label>
                    <input type="number" min="0" max="20" step="0.5" value="1" class="score-input mx-auto block border border-gray-300 rounded-lg px-3 py-2 text-center text-sm font-medium focus:outline-none focus:border-hbcu-700" data-key="feed_engagement_like_weight">
                    <div class="text-xs text-gray-400 mt-1">Default: 1</div>
                </div>
                <div class="text-center">
                    <label class="block text-xs text-gray-500 mb-1">View Weight</label>
                    <input type="number" min="0" max="5" step="0.1" value="0.1" class="score-input mx-auto block border border-gray-300 rounded-lg px-3 py-2 text-center text-sm font-medium focus:outline-none focus:border-hbcu-700" data-key="feed_engagement_view_weight">
                    <div class="text-xs text-gray-400 mt-1">Default: 0.1</div>
                </div>
                <div class="text-center">
                    <label class="block text-xs text-gray-500 mb-1">Cap</label>
                    <input type="number" min="0" max="100" value="20" class="score-input mx-auto block border border-gray-300 rounded-lg px-3 py-2 text-center text-sm font-medium focus:outline-none focus:border-hbcu-700 bg-gray-50" data-key="feed_engagement_cap">
                    <div class="text-xs text-gray-400 mt-1">Default: 20</div>
                </div>
            </div>
        </div>

        <!-- Recency Scoring -->
        <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
            <div class="flex items-center gap-2 mb-1">
                <svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
                <h2 class="text-lg font-semibold text-gray-800">Recency Scoring</h2>
            </div>
            <p class="text-sm text-gray-500 mb-4">Exponential decay: <code>max_score * 0.5^(hours_old / halflife)</code></p>
            <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
                <div class="text-center">
                    <label class="block text-xs text-gray-500 mb-1">Max Score</label>
                    <input type="number" min="0" max="100" value="30" class="score-input mx-auto block border border-gray-300 rounded-lg px-3 py-2 text-center text-sm font-medium focus:outline-none focus:border-hbcu-700" data-key="feed_recency_max_score">
                    <div class="text-xs text-gray-400 mt-1">Default: 30</div>
                </div>
                <div class="text-center">
                    <label class="block text-xs text-gray-500 mb-1">Half-Life (hours)</label>
                    <input type="number" min="1" max="720" value="72" class="score-input mx-auto block border border-gray-300 rounded-lg px-3 py-2 text-center text-sm font-medium focus:outline-none focus:border-hbcu-700" data-key="feed_recency_halflife_hours">
                    <div class="text-xs text-gray-400 mt-1">Default: 72 (3 days)</div>
                </div>
                <div class="text-center">
                    <label class="block text-xs text-gray-500 mb-1">Random Jitter</label>
                    <input type="number" min="0" max="20" value="5" class="score-input mx-auto block border border-gray-300 rounded-lg px-3 py-2 text-center text-sm font-medium focus:outline-none focus:border-hbcu-700" data-key="feed_jitter_max">
                    <div class="text-xs text-gray-400 mt-1">Default: 5</div>
                </div>
            </div>
        </div>

        <!-- Scoring Preview -->
        <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
            <h2 class="text-lg font-semibold text-gray-800 mb-3">Score Preview</h2>
            <p class="text-sm text-gray-500 mb-4">Approximate scores for common post scenarios with current settings</p>
            <div class="overflow-x-auto">
                <table class="w-full text-sm">
                    <thead>
                        <tr class="border-b border-gray-200">
                            <th class="text-left py-2 px-3 text-gray-600 font-medium">Scenario</th>
                            <th class="text-center py-2 px-3 text-blue-600 font-medium">Relationship</th>
                            <th class="text-center py-2 px-3 text-green-600 font-medium">Affinity</th>
                            <th class="text-center py-2 px-3 text-amber-600 font-medium">Engagement</th>
                            <th class="text-center py-2 px-3 text-purple-600 font-medium">Recency</th>
                            <th class="text-center py-2 px-3 text-hbcu-700 font-bold">Total</th>
                        </tr>
                    </thead>
                    <tbody id="previewTable"></tbody>
                </table>
            </div>
        </div>

    </div><!-- end scoring-section -->

</div><!-- end max-w-5xl -->

<!-- Save toast -->
<div id="saveToast" class="save-toast fixed bottom-6 right-6 bg-green-600 text-white px-5 py-3 rounded-xl shadow-lg text-sm font-medium flex items-center gap-2 z-50">
    <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
    Settings saved!
</div>

<script>
var currentAlgo = 'scored';
var defaults = {
    feed_algorithm: 'scored',
    feed_weight_relationship: '1.0', feed_weight_affinity: '1.0',
    feed_weight_engagement: '1.0', feed_weight_recency: '1.0',
    feed_relationship_friend: '40', feed_relationship_following: '25',
    feed_relationship_follower: '15', feed_relationship_same_school: '10',
    feed_relationship_same_state: '5',
    feed_affinity_same_school: '15', feed_affinity_same_major: '8',
    feed_affinity_same_state: '5', feed_affinity_same_city: '5',
    feed_affinity_same_org: '5', feed_affinity_cap: '30',
    feed_engagement_comment_weight: '3', feed_engagement_like_weight: '1',
    feed_engagement_view_weight: '0.1', feed_engagement_cap: '20',
    feed_recency_max_score: '30', feed_recency_halflife_hours: '72',
    feed_jitter_max: '5'
};

function loadSettings() {
    fetch('?action=get_settings')
        .then(r => r.json())
        .then(data => {
            if (!data.settings) return;
            var s = data.settings;
            // Set algorithm
            currentAlgo = s.feed_algorithm || 'scored';
            updateAlgoUI();
            // Set all inputs
            document.querySelectorAll('[data-key]').forEach(function(el) {
                if (s[el.dataset.key] !== undefined) {
                    el.value = s[el.dataset.key];
                    if (el.dataset.display) {
                        document.getElementById(el.dataset.display).textContent = parseFloat(el.value).toFixed(1);
                    }
                }
            });
            updatePreview();
        });
}

function selectAlgorithm(algo) {
    currentAlgo = algo;
    updateAlgoUI();
}

function updateAlgoUI() {
    var scored = document.getElementById('card-scored');
    var chrono = document.getElementById('card-chronological');
    var section = document.getElementById('scoring-section');
    if (currentAlgo === 'scored') {
        scored.className = 'algo-card border-2 border-hbcu-700 rounded-xl p-5 bg-hbcu-50 ring-2 ring-hbcu-200';
        chrono.className = 'algo-card border-2 border-gray-200 rounded-xl p-5';
        section.style.opacity = '1';
        section.style.pointerEvents = 'auto';
    } else {
        chrono.className = 'algo-card border-2 border-hbcu-700 rounded-xl p-5 bg-hbcu-50 ring-2 ring-hbcu-200';
        scored.className = 'algo-card border-2 border-gray-200 rounded-xl p-5';
        section.style.opacity = '0.4';
        section.style.pointerEvents = 'none';
    }
}

function updateSlider(el) {
    var display = document.getElementById(el.dataset.display);
    if (display) display.textContent = parseFloat(el.value).toFixed(1);
    updatePreview();
}

function getVal(key) {
    var el = document.querySelector('[data-key="' + key + '"]');
    return el ? parseFloat(el.value) || 0 : 0;
}

function updatePreview() {
    var wR = getVal('feed_weight_relationship');
    var wA = getVal('feed_weight_affinity');
    var wE = getVal('feed_weight_engagement');
    var wC = getVal('feed_weight_recency');
    var rFriend = getVal('feed_relationship_friend');
    var rFollowing = getVal('feed_relationship_following');
    var aSchool = getVal('feed_affinity_same_school');
    var aCap = getVal('feed_affinity_cap');
    var eCW = getVal('feed_engagement_comment_weight');
    var eLW = getVal('feed_engagement_like_weight');
    var eVW = getVal('feed_engagement_view_weight');
    var eCap = getVal('feed_engagement_cap');
    var recMax = getVal('feed_recency_max_score');
    var recHL = getVal('feed_recency_halflife_hours');

    function engScore(comments, likes, views) {
        return Math.min(eCap, Math.log2(1 + comments*eCW + likes*eLW + views*eVW) * 3);
    }
    function recScore(hoursAgo) {
        return recMax * Math.pow(0.5, hoursAgo / recHL);
    }

    var scenarios = [
        ['Friend, same school, 5 comments, 1hr old', rFriend, Math.min(aSchool, aCap), engScore(5,10,50), recScore(1)],
        ['Following, same school, 2 comments, 6hr old', rFollowing, Math.min(aSchool, aCap), engScore(2,5,20), recScore(6)],
        ['Stranger, same school, 0 engagement, 2hr old', 0, Math.min(aSchool, aCap), engScore(0,0,0), recScore(2)],
        ['Friend, diff school, 0 engagement, 48hr old', rFriend, 0, engScore(0,0,0), recScore(48)],
        ['Stranger, no match, 20 comments, 12hr old', 0, 0, engScore(20,50,200), recScore(12)],
        ['Stranger, no match, 0 engagement, 1hr old', 0, 0, engScore(0,0,0), recScore(1)],
    ];

    var html = '';
    scenarios.forEach(function(s) {
        var total = (s[1]*wR + s[2]*wA + s[3]*wE + s[4]*wC);
        html += '<tr class="border-b border-gray-100">';
        html += '<td class="py-2 px-3 text-gray-700">' + s[0] + '</td>';
        html += '<td class="py-2 px-3 text-center text-blue-600">' + (s[1]*wR).toFixed(1) + '</td>';
        html += '<td class="py-2 px-3 text-center text-green-600">' + (s[2]*wA).toFixed(1) + '</td>';
        html += '<td class="py-2 px-3 text-center text-amber-600">' + (s[3]*wE).toFixed(1) + '</td>';
        html += '<td class="py-2 px-3 text-center text-purple-600">' + (s[4]*wC).toFixed(1) + '</td>';
        html += '<td class="py-2 px-3 text-center text-hbcu-700 font-bold">' + total.toFixed(1) + '</td>';
        html += '</tr>';
    });
    document.getElementById('previewTable').innerHTML = html;
}

function collectSettings() {
    var settings = { feed_algorithm: currentAlgo };
    document.querySelectorAll('[data-key]').forEach(function(el) {
        settings[el.dataset.key] = el.value;
    });
    return settings;
}

function saveAllSettings() {
    var btn = document.getElementById('saveBtn');
    btn.disabled = true;
    btn.innerHTML = '<svg class="inline w-4 h-4 mr-1 -mt-0.5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> Saving...';

    var settings = collectSettings();
    var form = new URLSearchParams();
    form.append('action', 'save_settings');
    form.append('settings', JSON.stringify(settings));

    fetch('?', { method: 'POST', body: form })
        .then(r => r.json())
        .then(data => {
            btn.disabled = false;
            btn.innerHTML = '<svg class="inline w-4 h-4 mr-1 -mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> Save Changes';
            if (data.success) {
                showToast();
            } else {
                alert('Error saving: ' + (data.error || 'Unknown error'));
            }
        })
        .catch(function() {
            btn.disabled = false;
            btn.innerHTML = 'Save Changes';
            alert('Network error');
        });
}

function resetToDefaults() {
    if (!confirm('Reset all feed settings to their default values?')) return;
    fetch('?action=reset_defaults')
        .then(r => r.json())
        .then(data => {
            if (data.success) {
                // Reload from server
                loadSettings();
                showToast('Reset to defaults!');
            }
        });
}

function showToast(msg) {
    var toast = document.getElementById('saveToast');
    if (msg) toast.querySelector('svg + *') || (toast.lastChild.textContent = msg);
    toast.classList.add('show');
    setTimeout(function() { toast.classList.remove('show'); }, 2500);
}

// Debounce preview updates on number inputs
document.querySelectorAll('.score-input').forEach(function(el) {
    el.addEventListener('input', updatePreview);
});

// Init
loadSettings();
</script>
~;

    printAdminFooter();
}
```

**Step 2: Set permissions and syntax check**

```bash
chmod 755 admin/app/feed_settings.cgi
chown hbcuconnect:psacln admin/app/feed_settings.cgi
cd /var/www/vhosts/hbcuconnect.com/httpdocs/admin && perl -c app/feed_settings.cgi
```

---

### Task 4: Add Feed Settings Link to Admin Index

**Files:**
- Modify: `admin/index.cgi`

**Context:** The admin index page (`admin/index.cgi`) has category sections. We need to add a "Mobile App" section (since there isn't one yet) with cards for both the existing Content Moderation tool and the new Feed Settings tool.

**Step 1: Add the Mobile App section**

Insert a new section BEFORE the closing `</div><!-- end toolsContainer -->` (around line 371). Look for the `<!-- No results message -->` comment as the insertion point.

Add this right before `</div><!-- end toolsContainer -->`:

```perl
print qq~
    </div>
</div>

<!-- ============================================================ -->
<!-- SECTION: Mobile App -->
<!-- ============================================================ -->
<div class="category-section mb-8" data-category="app">
    <div class="flex items-center gap-2 mb-4">
        <div class="bg-violet-600 text-white p-1.5 rounded-lg">
            <svg class="section-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
        </div>
        <h2 class="text-lg font-bold text-gray-800">Mobile App</h2>
    </div>
    <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
~;

printSortedToolCards(
    ['app/content_moderation.cgi', 'Content Moderation', 'Review AI-flagged photos and videos, manage user reports', 'shield', 'violet'],
    ['app/feed_settings.cgi', 'Feed Algorithm', 'Configure feed scoring algorithm and personalization weights', 'sparkles', 'violet'],
);

print qq~
    </div>
</div>
~;
```

This replaces the existing closing `</div><!-- end toolsContainer -->` — make sure the toolsContainer close tag comes AFTER this new section.

**Step 2: Verify**

```bash
cd /var/www/vhosts/hbcuconnect.com/httpdocs/admin && perl -c index.cgi
chown hbcuconnect:psacln index.cgi
```

---

### Task 5: Verification and Testing

**Step 1: Insert default settings into database**

Run the SQL from Task 1 to seed the `app_settings` table.

**Step 2: Restart the API**

```bash
pm2 restart hbcu-api
tail -20 /var/log/hbcu-app/out-0.log
```

Verify no startup errors.

**Step 3: Test the admin page**

Navigate to `https://hbcuconnect.com/admin/app/feed_settings.cgi`

Verify:
- Page loads with all scoring groups
- Algorithm selector works (scored vs chronological)
- Slider values update display text
- Score Preview table shows calculated scores
- Save button works (check `app_settings` table after)
- Reset to Defaults works

**Step 4: Test the feed API**

```bash
# Test that the feed still works
curl -s -H "Authorization: Bearer <token>" https://hbcuconnect.com/api/media/feed | python3 -m json.tool | head -20
```

**Step 5: Test algorithm switching**

1. On admin page, select "Chronological" and save
2. Refresh app feed — should be newest-first
3. Switch back to "Scored" and save
4. Refresh app feed — should show personalized ordering

---

## Default Scoring Summary

| Factor | Max Points | Weight | Weighted Max |
|--------|-----------|--------|-------------|
| Relationship | 40 (friend) | 1.0x | 40 |
| Affinity | 30 (capped) | 1.0x | 30 |
| Engagement | 20 (capped) | 1.0x | 20 |
| Recency | 30 (just posted) | 1.0x | 30 |
| Jitter | 5 (random) | — | 5 |
| **Total possible** | | | **~125** |

Example: A post from a **friend** (40) who went to **your school** (15 affinity) with **5 comments and 10 likes** (~11 engagement) posted **1 hour ago** (~29 recency) scores approximately **95 + jitter** — virtually guaranteed to appear near the top.

A post from a **stranger** with **no affinity match**, **no engagement**, posted **3 days ago** (15 recency) scores approximately **15 + jitter** — will appear well below connected/relevant content but still surface eventually.
