/* ============================================================================
MOWDEW — Reschedule negotiation kit (shared)
The negotiation model (pending → proposed ⇄ counter → approved / declined /
cancelled) + the atoms it's built from: state chips, time-change block,
mini calendar + time picker, decline / propose / reassign forms, the
append-only thread, a desktop Modal, and the in-app notification system
(data + bell + feed rows) for all three personas.
Load AFTER mowdew-kit.jsx (+ mowdew-owner-data.jsx where CUSTOMERS is used).
Screens that compose these live in mowdew-resched-screens.jsx.
========================================================================= */
/* ─── Requests — one coherent May 2026 world (today = Tue, May 12) ──────
thread turns: { by: 'homeowner'|'owner', kind: 'request'|'propose'|
'counter'|'approve'|'decline'|'cancel', to?, note?, at } */
const RESCHED_REQS = [
{ id: 'q1', cust: 'c2', worker: 'w1', state: 'proposed', age: '2h ago',
orig: 'Mon, May 18 · 10 AM',
thread: [
{ by: 'homeowner', kind: 'request', to: 'Thu, May 21 · morning', note: 'Pool party Wednesday — could Diego come Thursday instead?', at: 'Tue 9:15 AM' },
{ by: 'owner', kind: 'propose', to: 'Sat, May 23 · 9 AM', note: "Thursday's full — would Saturday morning work?", at: 'Tue 11:02 AM' },
] },
{ id: 'q2', cust: 'c5', worker: 'w2', state: 'pending', age: '5h ago',
orig: 'Wed, May 20 · 1 PM',
thread: [
{ by: 'homeowner', kind: 'request', to: 'Fri, May 22 · 1 PM', note: 'Gate will be locked Wednesday.', at: 'Mon 6:30 PM' },
] },
{ id: 'q3', cust: 'c3', worker: 'w2', state: 'pending', age: '1d ago', rounds: 2,
orig: 'Mon, May 18 · 8:30 AM',
thread: [
{ by: 'homeowner', kind: 'request', to: 'Thu, May 21 · 8:30 AM', note: "We're repaving the driveway Monday.", at: 'Sun 4:10 PM' },
{ by: 'owner', kind: 'propose', to: 'Sat, May 23 · 8:30 AM', note: 'Marco is booked Thursday — Saturday instead?', at: 'Sun 6:45 PM' },
{ by: 'homeowner', kind: 'counter', to: 'Sun, May 24 · afternoon', note: "We're out Saturday — Sunday afternoon?", at: 'Mon 8:20 AM' },
] },
{ id: 'q4', cust: 'c6', worker: 'w1', state: 'approved', age: '4d ago',
orig: 'Tue, May 12 · 9 AM', final: 'Fri, May 15 · 2 PM',
thread: [
{ by: 'homeowner', kind: 'request', to: 'Fri, May 15 · 2 PM', note: 'Hosting a brunch Tuesday morning.', at: 'Fri 2:12 PM' },
{ by: 'owner', kind: 'approve', to: 'Fri, May 15 · 2 PM', at: 'Fri 3:05 PM' },
] },
{ id: 'q5', cust: 'c8', worker: 'w1', state: 'declined', age: '5d ago',
orig: 'Wed, May 13 · 11 AM',
thread: [
{ by: 'homeowner', kind: 'request', to: 'Thu, May 14 · 11 AM', note: 'Painters are coming Wednesday.', at: 'Thu 7:40 PM' },
{ by: 'owner', kind: 'decline', note: 'Too short notice — that day was already dispatched. Kept your Wednesday slot.', at: 'Thu 8:15 PM' },
] },
{ id: 'q6', cust: 'c1', worker: 'w1', state: 'cancelled', age: '1w ago',
orig: 'Mon, May 11 · 9 AM',
thread: [
{ by: 'homeowner', kind: 'request', to: 'Wed, May 13 · 9 AM', note: 'We might be travelling.', at: 'Mon 8:00 AM' },
{ by: 'homeowner', kind: 'cancel', note: 'Never mind — plans changed, Monday works.', at: 'Mon 9:30 AM' },
] },
];
function reqLatestTo(q) { const t = [...q.thread].reverse().find(x => x.to); return t ? t.to : q.orig; }
function reqNeedsOwner(q) { return q.state === 'pending'; }
function reqWaiting(q) { return q.state === 'proposed'; }
function reqResolved(q) { return q.state === 'approved' || q.state === 'declined' || q.state === 'cancelled'; }
function needsOwnerCount() { return RESCHED_REQS.filter(reqNeedsOwner).length; }
/* ─── State chip — viewer-aware labels ──────────────────────────────── */
const RESCHED_CHIP = {
pending: { tone: 'soon', owner: 'Needs you', homeowner: 'Pending', ownerShort: 'Needs you', homeShort: 'Pending' },
proposed: { tone: 'soon', owner: 'Waiting on customer', homeowner: 'New time suggested', ownerShort: 'Waiting', homeShort: 'New time' },
approved: { tone: 'done', owner: 'Confirmed', homeowner: 'Confirmed', icon: 'check', ownerShort: 'Confirmed', homeShort: 'Confirmed' },
declined: { tone: 'alert', owner: 'Declined', homeowner: 'Declined', ownerShort: 'Declined', homeShort: 'Declined' },
cancelled: { tone: 'neutral', owner: 'Cancelled', homeowner: 'Cancelled', ownerShort: 'Cancelled', homeShort: 'Cancelled' },
};
function ReschedChip({ state, viewer = 'owner', compact = false }) {
const m = RESCHED_CHIP[state] || RESCHED_CHIP.pending;
const label = compact ? (viewer === 'owner' ? m.ownerShort : m.homeShort) : m[viewer];
return {label} ;
}
/* ─── Time change — FROM → TO, the calendar handshake ───────────────── */
function TimeChange({ from, to, toLabel = 'TO', toTone = 'green', compact = false }) {
const toColor = toTone === 'alert' ? 'var(--alert-700)' : toTone === 'aqua' ? 'var(--aqua-700)' : 'var(--green-700)';
return (
);
}
/* ─── Mini calendar — May / Jun 2026, real weekday layout ───────────── */
const CAL_MONTHS = [
{ label: 'May 2026', days: 31, startCol: 5, today: 12 }, // May 1, 2026 = Friday
{ label: 'June 2026', days: 30, startCol: 1 }, // Jun 1, 2026 = Monday
];
function MiniCalendar({ value, onChange }) {
const [mi, setMi] = React.useState(0);
const m = CAL_MONTHS[mi];
const pick = (d) => onChange(`${m.label.split(' ')[0]} ${d}`);
const selected = (d) => value === `${m.label.split(' ')[0]} ${d}`;
const disabled = (d) => mi === 0 && m.today && d <= m.today;
return (
setMi(0)} disabled={mi === 0} aria-label="Previous month" style={{ width: 30, height: 30, borderRadius: 8, border: 'none', background: 'transparent', color: mi === 0 ? 'var(--ink-200)' : 'var(--ink-700)', display: 'grid', placeItems: 'center', cursor: mi === 0 ? 'default' : 'pointer' }}>
{m.label}
setMi(1)} disabled={mi === 1} aria-label="Next month" style={{ width: 30, height: 30, borderRadius: 8, border: 'none', background: 'transparent', color: mi === 1 ? 'var(--ink-200)' : 'var(--ink-700)', display: 'grid', placeItems: 'center', cursor: mi === 1 ? 'default' : 'pointer' }}>
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((d, i) => (
{d}
))}
{Array.from({ length: m.startCol }).map((_, i) => )}
{Array.from({ length: m.days }).map((_, i) => {
const d = i + 1; const on = selected(d); const off = disabled(d); const isToday = mi === 0 && d === m.today;
return (
!off && pick(d)} disabled={off}
style={{ height: 34, borderRadius: 9, border: isToday && !on ? '1.5px solid var(--green-300)' : 'none', cursor: off ? 'default' : 'pointer',
background: on ? 'var(--green-700)' : 'transparent', color: off ? 'var(--ink-200)' : on ? '#fff' : 'var(--ink-900)',
fontFamily: 'var(--font-body)', fontSize: 12.5, fontWeight: on ? 800 : 600 }}>
{d}
);
})}
);
}
/* ─── Time chips ────────────────────────────────────────────────────── */
const TIME_SLOTS = ['8 AM', '9 AM', '10 AM', '11 AM', '1 PM', '2 PM', '4 PM'];
function TimeChips({ value, onChange }) {
return (
{TIME_SLOTS.map(t => {
const on = value === t;
return (
onChange(t)}
style={{ padding: '9px 13px', borderRadius: 10, border: on ? '2px solid var(--green-500)' : '1.5px solid var(--ink-200)', background: on ? 'var(--green-50)' : '#fff', color: on ? 'var(--green-800)' : 'var(--ink-700)', fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>
{t}
);
})}
);
}
/* ─── Propose / counter form — calendar + time + note ───────────────── */
function ProposeForm({ submitLabel = 'Propose this time', notePlaceholder = 'Add a note (optional)…', onSubmit }) {
const [date, setDate] = React.useState(null);
const [time, setTime] = React.useState(null);
const [note, setNote] = React.useState('');
return (
);
}
/* ─── Decline form — reason required, quick chips ───────────────────── */
function DeclineForm({ quick, onSubmit, submitLabel = 'Decline with this reason' }) {
const chips = quick || ['Pro unavailable that day', 'Too short notice', 'Weather plan that week'];
const [reason, setReason] = React.useState('');
return (
{chips.map(c => (
setReason(c)}
style={{ padding: '8px 12px', borderRadius: 99, border: reason === c ? '2px solid var(--alert-500)' : '1.5px solid var(--ink-200)', background: reason === c ? 'var(--alert-50)' : '#fff', color: reason === c ? 'var(--alert-700)' : 'var(--ink-700)', fontSize: 12.5, fontWeight: 700, cursor: 'pointer' }}>
{c}
))}
);
}
/* ─── Reassign form — pro picker, optional approve-combo ────────────── */
function ReassignForm({ currentWorker, withApprove = false, onSubmit }) {
const [wid, setWid] = React.useState(null);
const w = WORKERS.find(x => x.id === wid);
return (
{WORKERS.map(nw => {
const cur = nw.id === currentWorker;
const on = wid === nw.id;
return (
!cur && setWid(nw.id)} disabled={cur}
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px', borderRadius: 'var(--radius-lg)', border: on ? '2px solid var(--green-500)' : '1.5px solid var(--ink-100)', background: on ? 'var(--green-50)' : cur ? 'var(--ink-50)' : '#fff', cursor: cur ? 'default' : 'pointer', textAlign: 'left', opacity: cur ? 0.7 : 1 }}>
{nw.name}{nw.you ? ' · you' : ''}
{nw.role} · {nw.today} stops today
{cur
? CURRENT
: {on && } }
);
})}
{withApprove ? (
onSubmit(wid, true)} disabled={!wid} className="btn btn-primary btn-block"> Approve & reassign{w ? ` to ${w.name.split(' ')[0]}` : ''}
onSubmit(wid, false)} disabled={!wid} className="btn btn-secondary btn-block">Reassign only — decide the time later
) : (
onSubmit(wid, false)} disabled={!wid} className="btn btn-primary btn-block"> Reassign{w ? ` to ${w.name.split(' ')[0]}` : ''}
)}
Both Pros are notified and routes rebalance automatically.
);
}
/* ─── Thread — append-only negotiation history (Outlook-invite style) ── */
const TURN_META = {
request: { icon: 'calendar-check', color: 'var(--yellow-700)', bg: 'var(--yellow-50)', verb: 'asked to move the visit' },
propose: { icon: 'clock', color: 'var(--yellow-700)', bg: 'var(--yellow-50)', verb: 'suggested a new time' },
counter: { icon: 'history', color: 'var(--yellow-700)', bg: 'var(--yellow-50)', verb: 'proposed another time' },
approve: { icon: 'check-circle', color: 'var(--green-700)', bg: 'var(--green-50)', verb: 'approved the new time' },
reassign: { icon: 'users', color: 'var(--blue-700)', bg: 'var(--blue-50)', verb: 'reassigned the stop' },
decline: { icon: 'x', color: 'var(--alert-700)', bg: 'var(--alert-50)', verb: 'declined' },
cancel: { icon: 'x', color: 'var(--ink-500)', bg: 'var(--ink-50)', verb: 'cancelled the request' },
};
/* names.owner / names.homeowner — what to call each side from this viewer */
function NegotiationThread({ thread, names, compact = false }) {
return (
{thread.map((t, i) => {
const m = TURN_META[t.kind] || TURN_META.request;
const who = names[t.by] || t.by;
const verb = t.kind === 'reassign' && t.toWho ? `moved this stop to ${t.toWho}'s route` : m.verb;
return (
{who} {verb}
{t.to && t.kind !== 'approve' && {t.to} }
{t.at}
{t.note &&
"{t.note}"
}
);
})}
);
}
/* ─── Modal — desktop dialog (mobile uses Sheet from mowdew-frame) ──── */
function Modal({ open, onClose, title, children, width = 470 }) {
if (!open) return null;
return (
e.stopPropagation()} style={{ width: '100%', maxWidth: width, maxHeight: '88vh', overflowY: 'auto', background: 'var(--surface)', borderRadius: 22, padding: '20px 22px 22px', boxShadow: 'var(--shadow-xl)' }}>
{title}
{children}
);
}
/* ============================================================================
IN-APP NOTIFICATIONS — shared system, three personas
========================================================================= */
const NOTIFS = {
owner: [
{ id: 'no1', icon: 'history', tone: 'yellow', title: 'New time proposed', body: 'Ada proposed Sun, May 24 · afternoon instead of your Saturday offer.', at: '1d ago', unread: true, link: 'resched' },
{ id: 'no2', icon: 'calendar-check', tone: 'yellow', title: 'New reschedule request', body: 'Tom wants to move his pool visit from Wed, May 20 to Fri, May 22.', at: '5h ago', unread: true, link: 'resched' },
{ id: 'no3', icon: 'calendar', tone: 'aqua', title: 'Time to bill — 3 customers due', body: 'Review who\u2019s being billed, then send them all.', at: 'Today 8:00 AM', unread: false, link: 'billing' },
{ id: 'no4', icon: 'check-circle', tone: 'green', title: 'Reschedule confirmed', body: 'The Cohens accepted — pool visit moved to Fri, May 15 · 2 PM. Your route is updated.', at: '4d ago', unread: false, link: 'resched' },
],
homeowner: [
{ id: 'nh1', icon: 'clock', tone: 'yellow', title: 'New time suggested', body: 'Diego can\u2019t do Thursday — he suggested Sat, May 23 · 9 AM. Tap to accept or propose another.', at: '2h ago', unread: true, link: 'resched' },
{ id: 'nh2', icon: 'invoice', tone: 'aqua', title: 'New invoice', body: 'Marta Lawn & Pool sent INV-0007 — $260.00 due May 26.', at: 'Today 9:12 AM', unread: true, link: 'invoice' },
{ id: 'nh3', icon: 'check-circle', tone: 'green', title: 'Visit verified', body: 'Monday\u2019s pool visit is on the record — 24 min on-site, before & after photos.', at: '1w ago', unread: false, link: 'feed' },
],
pro: [
{ id: 'np1', icon: 'route', tone: 'blue', title: 'Your route changed', body: 'Marin Vista pool moved to 2:30 PM today.', at: '1h ago', unread: true, link: 'today' },
{ id: 'np2', icon: 'plus', tone: 'green', title: 'New stop assigned', body: 'You picked up Cypress Ct pool — weekly, today 1:20 PM.', at: '2h ago', unread: true, link: 'today' },
{ id: 'np3', icon: 'calendar-check', tone: 'yellow', title: 'Schedule update', body: 'Brookhaven Ln garden — next Monday\u2019s visit moved to Thu, May 21.', at: '1d ago', unread: false, link: 'today' },
],
};
const NOTIF_TONE = {
yellow: { bg: 'var(--yellow-50)', fg: 'var(--yellow-700)' },
green: { bg: 'var(--green-50)', fg: 'var(--green-700)' },
aqua: { bg: 'var(--aqua-50)', fg: 'var(--aqua-700)' },
blue: { bg: 'var(--blue-50)', fg: 'var(--blue-700)' },
alert: { bg: 'var(--alert-50)', fg: 'var(--alert-700)' },
ink: { bg: 'var(--ink-50)', fg: 'var(--ink-700)' },
};
/* read-state shared across screens for the session */
function notifReadStore() { return (window.__notifRead = window.__notifRead || {}); }
function notifUnreadCount(persona) {
const read = notifReadStore();
return NOTIFS[persona].filter(n => n.unread && !read[n.id]).length;
}
/* ─── Bell with unread badge ────────────────────────────────────────── */
function NotifBell({ count = 0, onClick, size = 42, onDark = false }) {
return (
{count > 0 && (
{count}
)}
);
}
/* ─── One notification row ──────────────────────────────────────────── */
function NotifRow({ n, read, onOpen, first = false, compact = false }) {
const t = NOTIF_TONE[n.tone] || NOTIF_TONE.ink;
const isUnread = n.unread && !read;
return (
{n.title}
{n.at}
{n.body}
{isUnread &&
}
);
}
/* ─── Empty state — a mascot moment ─────────────────────────────────── */
function NotifEmpty({ persona = 'owner' }) {
const M = persona === 'homeowner' || persona === 'pro' ? Dewy : Sprout;
return (
All quiet.
When something needs you, it shows up here.
);
}
/* ─── Feed — list + mark-all-read + clear (shows the empty state) ───── */
function NotifFeed({ persona, onOpen, compact = false, frame = true }) {
const [, force] = React.useState(0);
const [cleared, setCleared] = React.useState(false);
const read = notifReadStore();
const items = cleared ? [] : NOTIFS[persona];
const unread = items.filter(n => n.unread && !read[n.id]).length;
const open = (n) => { read[n.id] = true; force(x => x + 1); onOpen && onOpen(n); };
const markAll = () => { items.forEach(n => { read[n.id] = true; }); force(x => x + 1); };
const body = items.length === 0 ? : items.map((n, i) => (
open(n)} />
));
if (!frame) return {body}
;
return (
Notifications
{unread > 0 && {unread} }
{unread > 0 && Mark all read }
{items.length > 0 && setCleared(true)} style={{ border: 'none', background: 'none', color: 'var(--ink-500)', fontWeight: 700, fontSize: 12, cursor: 'pointer', padding: '4px 6px' }}>Clear }
{body}
);
}
Object.assign(window, {
RESCHED_REQS, reqLatestTo, reqNeedsOwner, reqWaiting, reqResolved, needsOwnerCount,
RESCHED_CHIP, ReschedChip, TimeChange, MiniCalendar, TimeChips, TIME_SLOTS,
ProposeForm, DeclineForm, ReassignForm, NegotiationThread, TURN_META, Modal,
NOTIFS, NOTIF_TONE, notifReadStore, notifUnreadCount, NotifBell, NotifRow, NotifEmpty, NotifFeed,
});