diff --git a/services/arbiter-3.0/src/routes/admin/index.js b/services/arbiter-3.0/src/routes/admin/index.js
index 01ba936..30e81ab 100644
--- a/services/arbiter-3.0/src/routes/admin/index.js
+++ b/services/arbiter-3.0/src/routes/admin/index.js
@@ -16,6 +16,7 @@ const schedulerRouter = require('./scheduler');
const discordAuditRouter = require('./discord-audit');
const systemRouter = require('./system');
const socialRouter = require('./social');
+const socialCalendarRouter = require('./social-calendar');
const infrastructureRouter = require('./infrastructure');
const aboutRouter = require('./about');
const mcpLogsRouter = require('./mcp-logs');
@@ -123,6 +124,7 @@ router.use('/scheduler', schedulerRouter);
router.use('/discord', discordAuditRouter);
router.use('/system', systemRouter);
router.use('/social', socialRouter);
+router.use('/social-calendar', socialCalendarRouter);
router.use('/infrastructure', infrastructureRouter);
router.use('/about', aboutRouter);
router.use('/mcp-logs', mcpLogsRouter);
diff --git a/services/arbiter-3.0/src/routes/admin/social-calendar.js b/services/arbiter-3.0/src/routes/admin/social-calendar.js
new file mode 100644
index 0000000..d41abbc
--- /dev/null
+++ b/services/arbiter-3.0/src/routes/admin/social-calendar.js
@@ -0,0 +1,160 @@
+const express = require('express');
+const router = express.Router();
+const db = require('../../database');
+
+const PLATFORMS = ['tiktok','facebook','instagram','x','bluesky','youtube','twitch','reddit'];
+const STATUSES = ['draft','ready','scheduled','published','skipped'];
+
+// Parse ISO date (YYYY-MM-DD) into Monday of that week (local time)
+function weekStart(dateStr) {
+ const d = dateStr ? new Date(dateStr + 'T00:00:00') : new Date();
+ const day = d.getDay(); // 0=Sun..6=Sat
+ const diff = day === 0 ? -6 : 1 - day; // shift to Monday
+ d.setDate(d.getDate() + diff);
+ d.setHours(0,0,0,0);
+ return d;
+}
+
+function toISODate(d) {
+ return d.toISOString().slice(0,10);
+}
+
+// Split comma/space/newline hashtag input into array, strip leading #, dedupe
+function parseHashtags(raw) {
+ if (!raw) return [];
+ return [...new Set(
+ String(raw).split(/[\s,]+/).map(t => t.replace(/^#+/, '').trim()).filter(Boolean)
+ )];
+}
+
+// Shell route
+router.get('/', (req, res) => {
+ const start = weekStart(req.query.week);
+ res.render('admin/social-calendar/index', {
+ title: 'Social Calendar',
+ weekStartISO: toISODate(start),
+ PLATFORMS, STATUSES
+ });
+});
+
+// HTMX week view
+router.get('/week', async (req, res) => {
+ try {
+ const start = weekStart(req.query.week);
+ const end = new Date(start); end.setDate(end.getDate() + 7);
+ const { rows: plans } = await db.query(`
+ SELECT id, scheduled_at, platforms, status, caption, hashtags,
+ media_notes, link, assigned_to, notes
+ FROM social_post_plans
+ WHERE scheduled_at >= $1 AND scheduled_at < $2
+ ORDER BY scheduled_at ASC
+ `, [start, end]);
+
+ // Group by ISO date of scheduled_at
+ const byDay = {};
+ for (let i = 0; i < 7; i++) {
+ const d = new Date(start); d.setDate(d.getDate() + i);
+ byDay[toISODate(d)] = { date: d, plans: [] };
+ }
+ for (const p of plans) {
+ const key = toISODate(new Date(p.scheduled_at));
+ if (byDay[key]) byDay[key].plans.push(p);
+ }
+
+ const prev = new Date(start); prev.setDate(prev.getDate() - 7);
+ const next = new Date(start); next.setDate(next.getDate() + 7);
+
+ res.render('admin/social-calendar/_week', {
+ byDay, PLATFORMS, STATUSES,
+ weekStartISO: toISODate(start),
+ prevWeekISO: toISODate(prev),
+ nextWeekISO: toISODate(next),
+ thisWeekISO: toISODate(weekStart()),
+ layout: false
+ });
+ } catch (err) {
+ console.error('Social calendar week error:', err);
+ res.status(500).send("
Error loading calendar.
");
+ }
+});
+
+// Create plan
+router.post('/', async (req, res) => {
+ const { scheduled_at, platforms, status, caption, hashtags, media_notes, link, assigned_to, notes } = req.body;
+ const adminUsername = req.user.username;
+ try {
+ const platformsArr = Array.isArray(platforms) ? platforms : (platforms ? [platforms] : []);
+ const cleanPlatforms = platformsArr.filter(p => PLATFORMS.includes(p));
+ const cleanStatus = STATUSES.includes(status) ? status : 'draft';
+ const tagArr = parseHashtags(hashtags);
+ await db.query(`
+ INSERT INTO social_post_plans
+ (scheduled_at, platforms, status, caption, hashtags, media_notes, link, assigned_to, notes, created_by)
+ VALUES ($1, $2::social_platform[], $3, $4, $5, $6, $7, $8, $9, $10)
+ `, [scheduled_at, cleanPlatforms, cleanStatus, caption || '', tagArr,
+ media_notes || null, link || null, assigned_to || null, notes || null, adminUsername]);
+ res.set('HX-Trigger', 'refresh-week').status(201).send('');
+ } catch (err) {
+ console.error('Create plan error:', err);
+ res.status(500).send(`${err.message}
`);
+ }
+});
+
+// Update plan
+router.post('/:id/update', async (req, res) => {
+ const { id } = req.params;
+ const { scheduled_at, platforms, status, caption, hashtags, media_notes, link, assigned_to, notes } = req.body;
+ try {
+ const platformsArr = Array.isArray(platforms) ? platforms : (platforms ? [platforms] : []);
+ const cleanPlatforms = platformsArr.filter(p => PLATFORMS.includes(p));
+ const cleanStatus = STATUSES.includes(status) ? status : 'draft';
+ const tagArr = parseHashtags(hashtags);
+ await db.query(`
+ UPDATE social_post_plans
+ SET scheduled_at=$1, platforms=$2::social_platform[], status=$3, caption=$4,
+ hashtags=$5, media_notes=$6, link=$7, assigned_to=$8, notes=$9, updated_at=NOW()
+ WHERE id=$10
+ `, [scheduled_at, cleanPlatforms, cleanStatus, caption || '', tagArr,
+ media_notes || null, link || null, assigned_to || null, notes || null, id]);
+ res.set('HX-Trigger', 'refresh-week').status(200).send('');
+ } catch (err) {
+ console.error('Update plan error:', err);
+ res.status(500).send(`${err.message}
`);
+ }
+});
+
+// Delete plan
+router.post('/:id/delete', async (req, res) => {
+ const { id } = req.params;
+ try {
+ await db.query(`DELETE FROM social_post_plans WHERE id=$1`, [id]);
+ res.set('HX-Trigger', 'refresh-week').status(200).send('');
+ } catch (err) {
+ console.error('Delete plan error:', err);
+ res.status(500).send(`${err.message}
`);
+ }
+});
+
+// Edit form (returns modal body)
+router.get('/:id/edit', async (req, res) => {
+ try {
+ const { rows } = await db.query(`SELECT * FROM social_post_plans WHERE id=$1`, [req.params.id]);
+ if (!rows[0]) return res.status(404).send('Not found');
+ res.render('admin/social-calendar/_form', {
+ plan: rows[0], PLATFORMS, STATUSES, layout: false
+ });
+ } catch (err) {
+ console.error('Edit form error:', err);
+ res.status(500).send('Error');
+ }
+});
+
+// New form (blank modal body, optionally prefilled with date)
+router.get('/new', (req, res) => {
+ const date = req.query.date || new Date().toISOString().slice(0,10);
+ const plan = { id: null, scheduled_at: `${date}T12:00`, platforms: [], status: 'draft',
+ caption: '', hashtags: [], media_notes: '', link: '', assigned_to: '', notes: '' };
+ res.render('admin/social-calendar/_form', { plan, PLATFORMS, STATUSES, layout: false });
+});
+
+module.exports = router;
diff --git a/services/arbiter-3.0/src/views/admin/social-calendar/_form.ejs b/services/arbiter-3.0/src/views/admin/social-calendar/_form.ejs
new file mode 100644
index 0000000..7cfdf3b
--- /dev/null
+++ b/services/arbiter-3.0/src/views/admin/social-calendar/_form.ejs
@@ -0,0 +1,88 @@
+<% const isNew = !plan.id;
+ const action = isNew ? '/admin/social-calendar' : `/admin/social-calendar/${plan.id}/update`;
+ const scheduledValue = typeof plan.scheduled_at === 'string' ? plan.scheduled_at : new Date(plan.scheduled_at).toISOString().slice(0,16);
+ const hashtagsValue = Array.isArray(plan.hashtags) ? plan.hashtags.join(' ') : (plan.hashtags || ''); %>
+
+
+
+
<%= isNew ? '➕ New Post Plan' : `✏️ Edit Post #${plan.id}` %>
+
+
+
+
+
diff --git a/services/arbiter-3.0/src/views/admin/social-calendar/_week.ejs b/services/arbiter-3.0/src/views/admin/social-calendar/_week.ejs
new file mode 100644
index 0000000..dfbb492
--- /dev/null
+++ b/services/arbiter-3.0/src/views/admin/social-calendar/_week.ejs
@@ -0,0 +1,54 @@
+<% const platformIcons = {tiktok:'🎵',facebook:'📘',instagram:'📷',x:'✖️',bluesky:'🦋',youtube:'▶️',twitch:'🎮',reddit:'🤖'}; %>
+<% const statusColors = {draft:'bg-gray-200 text-gray-800',ready:'bg-blue-100 text-blue-800',scheduled:'bg-purple-100 text-purple-800',published:'bg-green-100 text-green-800',skipped:'bg-red-100 text-red-800'}; %>
+
+
+
+
+
Week of <%= new Date(weekStartISO + 'T00:00:00').toLocaleDateString(undefined, {month:'long', day:'numeric', year:'numeric'}) %>
+
+
+
+
+
+
+ <% Object.keys(byDay).forEach(function(iso) { %>
+ <% const day = byDay[iso]; %>
+ <% const dayName = day.date.toLocaleDateString(undefined, {weekday:'short'}); %>
+ <% const dayNum = day.date.getDate(); %>
+
+
+
+
<%= dayName %>
+
<%= dayNum %>
+
+
+
+
+ <% if (day.plans.length === 0) { %>
+
No posts
+ <% } %>
+ <% day.plans.forEach(function(p) { %>
+
+
+
+ <% (p.platforms || []).forEach(function(pl) { %>
+ <%= platformIcons[pl] || pl %>
+ <% }); %>
+
+
<%= new Date(p.scheduled_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}) %>
+
+
<%= p.caption || '(no caption)' %>
+
<%= p.status %>
+
+ <% }); %>
+
+
+ <% }); %>
+
diff --git a/services/arbiter-3.0/src/views/admin/social-calendar/index.ejs b/services/arbiter-3.0/src/views/admin/social-calendar/index.ejs
new file mode 100644
index 0000000..0762f82
--- /dev/null
+++ b/services/arbiter-3.0/src/views/admin/social-calendar/index.ejs
@@ -0,0 +1,41 @@
+
+
+
+ 📅 Social Content Calendar
+
+
Plan posts across 8 platforms — for Meg
+
+
+
+
+
+
+
+
⏳
+
Loading calendar...
+
+
+
+
+
+
+
+
diff --git a/services/arbiter-3.0/src/views/layout.ejs b/services/arbiter-3.0/src/views/layout.ejs
index d90ba29..72f829a 100644
--- a/services/arbiter-3.0/src/views/layout.ejs
+++ b/services/arbiter-3.0/src/views/layout.ejs
@@ -103,9 +103,12 @@
💬 Discord
-
+
📈 Social
+
+ 📅 Social Calendar
+
Infrastructure