Require a due date on all reminder creation (v0.1.0:103)

A date-less reminder has no urgency — it lands in the "Later"/"No date" bucket,
out of the overdue/today/this-week rollups and the daily digest — so every
create flow now pre-fills the due date to +1 week (editable) and blocks an empty
save. Shared reminderDefaultDue() helper; edit paths also pre-fill the default
for legacy date-less reminders.

Surfaces:
- Mobile: add-investor sheet (date auto-fills when you start the optional
  reminder), standalone Reminders "New reminder", Grid-detail "Set a reminder".
- Desktop: Reminders page "+ New reminder", grid reminder modal.

Server still accepts a null due_date by design (bot/automation callers); this is
a human-UI requirement. Frontend-only; no schema/migration/dependency change.
This commit is contained in:
Keysat
2026-06-20 16:51:03 -05:00
parent fea88b6557
commit d6250f74d0
5 changed files with 53 additions and 18 deletions
+5 -4
View File
File diff suppressed because one or more lines are too long
+21 -10
View File
@@ -4953,7 +4953,7 @@
const [onlyMine, setOnlyMine] = useState(false); const [onlyMine, setOnlyMine] = useState(false);
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [createForm, setCreateForm] = useState({ title: '', due_date: '', details: '', investor_name: '', assignee_id: '' }); const [createForm, setCreateForm] = useState({ title: '', due_date: reminderDefaultDue(), details: '', investor_name: '', assignee_id: '' });
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState(null); const [editing, setEditing] = useState(null);
const [editForm, setEditForm] = useState({ title: '', due_date: '', details: '', status: 'open', assignee_id: '' }); const [editForm, setEditForm] = useState({ title: '', due_date: '', details: '', status: 'open', assignee_id: '' });
@@ -5021,6 +5021,7 @@
const submitCreate = async () => { const submitCreate = async () => {
const title = (createForm.title || '').trim(); const title = (createForm.title || '').trim();
if (!title) { onShowToast('A reminder needs a title', 'error'); return; } if (!title) { onShowToast('A reminder needs a title', 'error'); return; }
if (!createForm.due_date) { onShowToast('A reminder needs a due date', 'error'); return; }
setCreating(true); setCreating(true);
try { try {
await api('/api/reminders', { method: 'POST', body: JSON.stringify({ await api('/api/reminders', { method: 'POST', body: JSON.stringify({
@@ -5029,7 +5030,7 @@
}) }, token); }) }, token);
onShowToast('Reminder created', 'success'); onShowToast('Reminder created', 'success');
setShowCreate(false); setShowCreate(false);
setCreateForm({ title: '', due_date: '', details: '', investor_name: '', assignee_id: '' }); setCreateForm({ title: '', due_date: reminderDefaultDue(), details: '', investor_name: '', assignee_id: '' });
load(); load();
} catch (err) { onShowToast(getErrorMessage(err, 'Create failed'), 'error'); } } catch (err) { onShowToast(getErrorMessage(err, 'Create failed'), 'error'); }
finally { setCreating(false); } finally { setCreating(false); }
@@ -5063,7 +5064,7 @@
<div className="page-container"> <div className="page-container">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px', flexWrap: 'wrap', gap: '10px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px', flexWrap: 'wrap', gap: '10px' }}>
<h2 className="section-title" style={{ margin: 0 }}>Reminders</h2> <h2 className="section-title" style={{ margin: 0 }}>Reminders</h2>
<button type="button" onClick={() => setShowCreate(true)}>+ New reminder</button> <button type="button" onClick={() => { setCreateForm((f) => ({ ...f, due_date: f.due_date || reminderDefaultDue() })); setShowCreate(true); }}>+ New reminder</button>
</div> </div>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', marginBottom: '16px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '12px', alignItems: 'center', marginBottom: '16px', flexWrap: 'wrap' }}>
@@ -5211,6 +5212,11 @@
const mk = (days) => { const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + days); return d.toISOString().slice(0, 10); }; const mk = (days) => { const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + days); return d.toISOString().slice(0, 10); };
return [['Tomorrow', mk(1)], ['In 3 days', mk(3)], ['1 week', mk(7)], ['2 weeks', mk(14)]]; return [['Tomorrow', mk(1)], ['In 3 days', mk(3)], ['1 week', mk(7)], ['2 weeks', mk(14)]];
}; };
// Default reminder due date — one week out, local-midnight → YYYY-MM-DD (same convention as
// the snooze presets). Every create flow pre-fills this and blocks an empty save: a reminder
// needs a date or it has no urgency (it falls to the "Later"/"No date" bucket, out of the
// overdue/today/this-week rollups + the daily digest).
const reminderDefaultDue = () => { const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + 7); return d.toISOString().slice(0, 10); };
// Active-reminder bucket (Overdue/Today/This week/Later, dc :318) — split by due-date delta. // Active-reminder bucket (Overdue/Today/This week/Later, dc :318) — split by due-date delta.
// Terminal items (done/cancelled) are filtered out before bucketing and rendered separately. // Terminal items (done/cancelled) are filtered out before bucketing and rendered separately.
const reminderBucket = (r) => { const reminderBucket = (r) => {
@@ -5384,12 +5390,12 @@
}, [investors, investorQuery]); }, [investors, investorQuery]);
const openCreate = () => { const openCreate = () => {
setForm({ title: '', due_date: '', details: '', investor_name: '', investor_source_row_id: '', assignee_id: '', status: 'open' }); setForm({ title: '', due_date: reminderDefaultDue(), details: '', investor_name: '', investor_source_row_id: '', assignee_id: '', status: 'open' });
setEditing({ create: true }); setEditing({ create: true });
}; };
const openEdit = (r) => { const openEdit = (r) => {
setForm({ setForm({
title: r.title || '', due_date: (r.due_date || '').slice(0, 10), details: r.details || '', title: r.title || '', due_date: (r.due_date || '').slice(0, 10) || reminderDefaultDue(), details: r.details || '',
investor_name: r.investor_name || '', investor_source_row_id: '', assignee_id: r.assignee_id || '', status: r.status || 'open', investor_name: r.investor_name || '', investor_source_row_id: '', assignee_id: r.assignee_id || '', status: r.status || 'open',
}); });
setEditing(r); setEditing(r);
@@ -5399,6 +5405,7 @@
const submit = async () => { const submit = async () => {
const title = (form.title || '').trim(); const title = (form.title || '').trim();
if (!title) { onShowToast('A reminder needs a title', 'error'); return; } if (!title) { onShowToast('A reminder needs a title', 'error'); return; }
if (!form.due_date) { onShowToast('A reminder needs a due date', 'error'); return; }
setBusy(true); setBusy(true);
try { try {
if (editing && editing.create) { if (editing && editing.create) {
@@ -8374,7 +8381,7 @@
const openReminderModal = async (row) => { const openReminderModal = async (row) => {
if (!row) return; if (!row) return;
setReminderContext({ rowId: row.id, investorName: row.investor_name || '' }); setReminderContext({ rowId: row.id, investorName: row.investor_name || '' });
setReminderForm({ title: '', due_date: '', details: '' }); setReminderForm({ title: '', due_date: reminderDefaultDue(), details: '' });
setReminderList([]); setReminderList([]);
setShowReminderModal(true); setShowReminderModal(true);
await loadReminders(row.id); await loadReminders(row.id);
@@ -8384,6 +8391,7 @@
if (!reminderContext?.rowId) return; if (!reminderContext?.rowId) return;
const title = (reminderForm.title || '').trim(); const title = (reminderForm.title || '').trim();
if (!title) { onShowToast('A reminder needs a title', 'error'); return; } if (!title) { onShowToast('A reminder needs a title', 'error'); return; }
if (!reminderForm.due_date) { onShowToast('A reminder needs a due date', 'error'); return; }
setReminderSubmitting(true); setReminderSubmitting(true);
try { try {
await api('/api/reminders', { await api('/api/reminders', {
@@ -8397,7 +8405,7 @@
}), }),
}, token); }, token);
onShowToast('Reminder set', 'success'); onShowToast('Reminder set', 'success');
setReminderForm({ title: '', due_date: '', details: '' }); setReminderForm({ title: '', due_date: reminderDefaultDue(), details: '' });
await loadReminders(reminderContext.rowId); await loadReminders(reminderContext.rowId);
} catch (err) { } catch (err) {
onShowToast(getErrorMessage(err, 'Failed to set reminder'), 'error'); onShowToast(getErrorMessage(err, 'Failed to set reminder'), 'error');
@@ -10560,6 +10568,7 @@
const submitReminder = async () => { const submitReminder = async () => {
const row = selectedRow; if (!row) return; const row = selectedRow; if (!row) return;
if (!String(reminderForm.title || '').trim()) { onShowToast('A reminder needs a title', 'error'); return; } if (!String(reminderForm.title || '').trim()) { onShowToast('A reminder needs a title', 'error'); return; }
if (!reminderForm.due_date) { onShowToast('A reminder needs a due date', 'error'); return; }
setBusy(true); setBusy(true);
try { try {
if (reminder && reminder.id) { if (reminder && reminder.id) {
@@ -10595,6 +10604,8 @@
const cName = createForm.contactName.trim(); const cName = createForm.contactName.trim();
if (!name) { onShowToast('Investor name is required', 'error'); return; } if (!name) { onShowToast('Investor name is required', 'error'); return; }
if (!cName) { onShowToast('Add at least one contact name', 'error'); return; } if (!cName) { onShowToast('Add at least one contact name', 'error'); return; }
// A reminder needs a date (it's pre-filled when you start one; block if it was cleared).
if (createForm.reminderTitle.trim() && !createForm.reminderDue) { onShowToast('Set a due date for the reminder', 'error'); return; }
setBusy(true); setBusy(true);
try { try {
// The one-row create path: log-communication finds-or-creates the investor + first // The one-row create path: log-communication finds-or-creates the investor + first
@@ -10802,7 +10813,7 @@
</div> </div>
<div className="sheet-field"> <div className="sheet-field">
<label className="sheet-field-label">Reminder (optional)</label> <label className="sheet-field-label">Reminder (optional)</label>
<input className="sheet-input" value={createForm.reminderTitle} onChange={(e) => setCreateForm((f) => ({ ...f, reminderTitle: e.target.value }))} placeholder="e.g. Send Fund III deck" /> <input className="sheet-input" value={createForm.reminderTitle} onChange={(e) => setCreateForm((f) => ({ ...f, reminderTitle: e.target.value, reminderDue: (e.target.value.trim() && !f.reminderDue) ? reminderDefaultDue() : f.reminderDue }))} placeholder="e.g. Send Fund III deck" />
</div> </div>
{createForm.reminderTitle.trim() && ( {createForm.reminderTitle.trim() && (
<div className="sheet-field"> <div className="sheet-field">
@@ -10889,8 +10900,8 @@
<div className="fs-section-label">Reminder</div> <div className="fs-section-label">Reminder</div>
<button className="detail-tap-card" disabled={reminder === undefined} onClick={() => { <button className="detail-tap-card" disabled={reminder === undefined} onClick={() => {
setReminderForm(reminder setReminderForm(reminder
? { title: reminder.title || '', due_date: (reminder.due_date || '').slice(0, 10), details: reminder.details || '' } ? { title: reminder.title || '', due_date: (reminder.due_date || '').slice(0, 10) || reminderDefaultDue(), details: reminder.details || '' }
: { title: '', due_date: '', details: '' }); : { title: '', due_date: reminderDefaultDue(), details: '' });
setSheet('reminder'); setSheet('reminder');
}}> }}>
{reminder === undefined ? ( {reminder === undefined ? (
+3 -2
View File
@@ -67,8 +67,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
// * 0.1.0:99 (Grant device-test round 2, CRM half: intake fuzzy match scores DISTINCTIVE tokens only [no more "Investment Group"/"Capital"/"Family Office" false look-alikes]; mobile grid "Last contact"/staleness sort is reversible; mobile Edit-investor prefills a contact's email [GET /api/fundraising/state heals a blank grid pill from the linked classic contact, fill-only]; mobile quick-log pencil icon renders [CSS sizing on the sole flex-child svg]. The Matrix intake thread-redaction change ships on the Spark, not here. No schema change; no migration) // * 0.1.0:99 (Grant device-test round 2, CRM half: intake fuzzy match scores DISTINCTIVE tokens only [no more "Investment Group"/"Capital"/"Family Office" false look-alikes]; mobile grid "Last contact"/staleness sort is reversible; mobile Edit-investor prefills a contact's email [GET /api/fundraising/state heals a blank grid pill from the linked classic contact, fill-only]; mobile quick-log pencil icon renders [CSS sizing on the sole flex-child svg]. The Matrix intake thread-redaction change ships on the Spark, not here. No schema change; no migration)
// * 0.1.0:100 (In-app business-card intake [#7]: a mobile camera button [left of the quick-log pencil] takes/picks a card photo, downscales it client-side via <canvas> to JPEG, and POSTs to the new POST /api/intake/card — vision-transcribe + parse + fuzzy-match on the box [local VL via Spark Control, nothing to Claude], reusing the Matrix card flow's nio-free parse/spark core. An editable review sheet [proposal fields + existing-investor picker] writes via log-communication tagged source="app_card"; a human approves every write. No schema change; no migration; no new dependency) // * 0.1.0:100 (In-app business-card intake [#7]: a mobile camera button [left of the quick-log pencil] takes/picks a card photo, downscales it client-side via <canvas> to JPEG, and POSTs to the new POST /api/intake/card — vision-transcribe + parse + fuzzy-match on the box [local VL via Spark Control, nothing to Claude], reusing the Matrix card flow's nio-free parse/spark core. An editable review sheet [proposal fields + existing-investor picker] writes via log-communication tagged source="app_card"; a human approves every write. No schema change; no migration; no new dependency)
// * 0.1.0:101 (Mobile UX batch 1 [Grant device feedback]: [1] inline ✕ clear button on the Grid/Contacts search + reminder/quick-log investor pickers [ClearableInput]; [2] Grid investor-detail contact pills are tappable — name deep-links to the Contacts detail [new Grid→Contacts one-shot action], email opens mailto; [4a] mobile Pipeline is a full-height flex column so the whole area above the now bottom-pinned dots is the swipe target, each stage page scrolling its cards; [4b] expected-amount entry — optional amount when adding to the pipeline from the Grid detail [feeds pipeline/link], editable amount on the Pipeline card detail [PUT /api/opportunities/{id}]; [5] bottom sheets lift above the on-screen keyboard [visualViewport] so the reminder investor-picker results stay visible. Grid contact-name search [#3] already worked. CSS+React only; no schema change; no migration; no new dependency) // * 0.1.0:101 (Mobile UX batch 1 [Grant device feedback]: [1] inline ✕ clear button on the Grid/Contacts search + reminder/quick-log investor pickers [ClearableInput]; [2] Grid investor-detail contact pills are tappable — name deep-links to the Contacts detail [new Grid→Contacts one-shot action], email opens mailto; [4a] mobile Pipeline is a full-height flex column so the whole area above the now bottom-pinned dots is the swipe target, each stage page scrolling its cards; [4b] expected-amount entry — optional amount when adding to the pipeline from the Grid detail [feeds pipeline/link], editable amount on the Pipeline card detail [PUT /api/opportunities/{id}]; [5] bottom sheets lift above the on-screen keyboard [visualViewport] so the reminder investor-picker results stay visible. Grid contact-name search [#3] already worked. CSS+React only; no schema change; no migration; no new dependency)
// * Current: 0.1.0:102 (Mobile email-approval bell [#6]: an admin-only bell in the mobile top bar [left of the camera] with an iPhone-style count badge surfaces the SAME pending email-capture proposals the web "Email Capture" panel + the Matrix review room decide. Tap → card list of proposals → tap one → review screen [investor name + subject + summary + editable proposed note] → Approve & log to grid / Reject. Reuses the existing GET /api/activity/proposals + POST .../{id}/approve|dismiss [require_admin]; bidirectional sync is automatic — an app decision flips the proposal status and the bot's poll redacts the Matrix thread, while a Matrix/web decision drops the proposal from the pending list the bell polls [45s], clearing the badge. No LLM round-trip [edit-then-approve like the web panel]; mobile-gated so the hidden desktop top bar doesn't poll. Frontend-only; no schema change; no migration; no new dependency) // * 0.1.0:102 (Mobile email-approval bell [#6]: an admin-only bell in the mobile top bar [left of the camera] with an iPhone-style count badge surfaces the SAME pending email-capture proposals the web "Email Capture" panel + the Matrix review room decide. Tap → card list of proposals → tap one → review screen [investor name + subject + summary + editable proposed note] → Approve & log to grid / Reject. Reuses the existing GET /api/activity/proposals + POST .../{id}/approve|dismiss [require_admin]; bidirectional sync is automatic — an app decision flips the proposal status and the bot's poll redacts the Matrix thread, while a Matrix/web decision drops the proposal from the pending list the bell polls [45s], clearing the badge. No LLM round-trip [edit-then-approve like the web panel]; mobile-gated so the hidden desktop top bar doesn't poll. Frontend-only; no schema change; no migration; no new dependency)
export const PACKAGE_VERSION = '0.1.0:102' // * Current: 0.1.0:103 (Reminders require a due date [Grant feedback]: every reminder-create flow now pre-fills the due date to +1 week [editable] and blocks an empty save — a date-less reminder has no urgency [it falls to the "Later"/"No date" bucket, out of the overdue/today/this-week rollups + daily digest]. Applies to ALL create surfaces via a shared `reminderDefaultDue()` helper — mobile: the add-investor sheet [date auto-fills when you start the optional reminder], the standalone Reminders "New reminder" sheet, the Grid-detail "Set a reminder" card; desktop: the Reminders page "+ New reminder" + the grid reminder modal. Edit paths also pre-fill the default for legacy date-less reminders. Frontend-only; no schema/migration/dependency change)
export const PACKAGE_VERSION = '0.1.0:103'
export const DATA_MOUNT_PATH = '/data' export const DATA_MOUNT_PATH = '/data'
export const WEB_PORT = 8080 export const WEB_PORT = 8080
+3 -2
View File
@@ -63,8 +63,9 @@ import { v_0_1_0_99 } from './v0.1.0.99'
import { v_0_1_0_100 } from './v0.1.0.100' import { v_0_1_0_100 } from './v0.1.0.100'
import { v_0_1_0_101 } from './v0.1.0.101' import { v_0_1_0_101 } from './v0.1.0.101'
import { v_0_1_0_102 } from './v0.1.0.102' import { v_0_1_0_102 } from './v0.1.0.102'
import { v_0_1_0_103 } from './v0.1.0.103'
export const versionGraph = VersionGraph.of({ export const versionGraph = VersionGraph.of({
current: v_0_1_0_102, current: v_0_1_0_103,
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90, v_0_1_0_91, v_0_1_0_92, v_0_1_0_93, v_0_1_0_94, v_0_1_0_95, v_0_1_0_96, v_0_1_0_97, v_0_1_0_98, v_0_1_0_99, v_0_1_0_100, v_0_1_0_101], other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90, v_0_1_0_91, v_0_1_0_92, v_0_1_0_93, v_0_1_0_94, v_0_1_0_95, v_0_1_0_96, v_0_1_0_97, v_0_1_0_98, v_0_1_0_99, v_0_1_0_100, v_0_1_0_101, v_0_1_0_102],
}) })
+21
View File
@@ -0,0 +1,21 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Reminders require a due date (Grant feedback). A date-less reminder has no urgency — it falls
// to the "Later"/"No date" bucket, out of the overdue/today/this-week rollups and the daily
// digest — so every create flow now pre-fills the due date to +1 week (editable) and blocks an
// empty save. Applies to ALL create surfaces via a shared reminderDefaultDue() helper — mobile:
// the add-investor sheet (the date auto-fills the moment you start the optional reminder), the
// standalone Reminders "New reminder" sheet, the Grid-detail "Set a reminder" card; desktop: the
// Reminders page "+ New reminder" and the grid reminder modal. Edit paths also pre-fill the
// default for legacy date-less reminders. Frontend-only; no schema, migration, or dependency change.
export const v_0_1_0_103 = VersionInfo.of({
version: '0.1.0:103',
releaseNotes: {
en_US: [
'Reminders now always have a due date: every create flow on mobile pre-fills it to a week',
'out (editable) and wont save without one, so a reminder cant slip into a no-date state',
'that hides it from the Overdue / Today / This week views and the daily digest.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})