Implement Issue #2: Scheduling & Publishing
Some checks failed
CI / Go tests & lint (push) Successful in 1m42s
CI / Frontend tests & type-check (push) Failing after 1m38s
CI / Go tests & lint (pull_request) Successful in 8s
CI / Frontend tests & type-check (pull_request) Failing after 32s

Replaces stub schedule CRUD with full shift template + instance system.

- DB: add shift_templates, shift_template_roles, shift_template_volunteers,
  shift_instances, shift_instance_volunteers tables
- schedule package: ShiftTemplate and ShiftInstance models with store
  (generate, publish/unpublish, per-instance edits, volunteer confirmation)
- API: shift-templates CRUD + shifts generate/publish/unpublish/update/confirm
- Notifications sent on publish (FR-S04), unpublish (FR-S05), instance edit
  (FR-S09), and volunteer added mid-month (FR-S10)
- Frontend: Schedules page with month navigation, template management,
  publish/unpublish controls, and per-shift edit/confirm
- Tests: Go handler tests (14 cases) + React tests (11 cases)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 11:40:41 -03:00
parent 96a363d28f
commit fc88b8f005
9 changed files with 2168 additions and 233 deletions

View File

@@ -0,0 +1,168 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import Schedules from './Schedules';
import { api, ShiftInstance, ShiftTemplate } from '../api';
jest.mock('../api', () => ({
...jest.requireActual('../api'),
api: {
listShifts: jest.fn(),
listShiftTemplates: jest.fn(),
generateShifts: jest.fn(),
publishShifts: jest.fn(),
unpublishShifts: jest.fn(),
updateShift: jest.fn(),
confirmShift: jest.fn(),
createShiftTemplate: jest.fn(),
updateShiftTemplate: jest.fn(),
deleteShiftTemplate: jest.fn(),
},
}));
jest.mock('../auth', () => ({
useAuth: jest.fn(),
}));
const { useAuth } = require('../auth');
const mockDraftInstance: ShiftInstance = {
id: 1,
name: 'Morning Shift',
date: '2026-04-06',
start_time: '09:00:00',
end_time: '12:00:00',
min_capacity: 2,
max_capacity: 5,
status: 'draft',
year: 2026,
month: 4,
volunteers: [],
created_at: '2026-04-01T00:00:00Z',
updated_at: '2026-04-01T00:00:00Z',
};
const mockPublishedInstance: ShiftInstance = {
...mockDraftInstance,
id: 2,
status: 'published',
volunteers: [{ instance_id: 2, volunteer_id: 10, name: 'Alice', confirmed: false }],
};
const mockTemplate: ShiftTemplate = {
id: 1,
name: 'Morning Shift',
day_of_week: 1,
start_time: '09:00:00',
end_time: '12:00:00',
min_capacity: 2,
max_capacity: 5,
roles: [],
volunteer_ids: [],
created_at: '2026-04-01T00:00:00Z',
updated_at: '2026-04-01T00:00:00Z',
};
describe('Schedules (volunteer view)', () => {
beforeEach(() => {
useAuth.mockReturnValue({ role: 'volunteer', volunteerID: 10 });
(api.listShifts as jest.Mock).mockResolvedValue([mockPublishedInstance]);
});
it('renders published shifts for a volunteer', async () => {
render(<Schedules />);
await waitFor(() => {
expect(screen.getByText('Morning Shift')).toBeInTheDocument();
});
expect(screen.getByText('published')).toBeInTheDocument();
});
it('does not show admin controls', async () => {
render(<Schedules />);
await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument());
expect(screen.queryByText('Generate')).not.toBeInTheDocument();
expect(screen.queryByText('Publish')).not.toBeInTheDocument();
expect(screen.queryByText('Manage Templates')).not.toBeInTheDocument();
});
});
describe('Schedules (admin view)', () => {
beforeEach(() => {
useAuth.mockReturnValue({ role: 'admin', volunteerID: 1 });
(api.listShifts as jest.Mock).mockResolvedValue([mockDraftInstance]);
(api.listShiftTemplates as jest.Mock).mockResolvedValue([mockTemplate]);
});
it('shows Generate and Publish buttons when drafts exist', async () => {
render(<Schedules />);
await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument());
expect(screen.getByText('Generate')).toBeInTheDocument();
expect(screen.getByText('Publish')).toBeInTheDocument();
});
it('calls generateShifts on Generate click', async () => {
(api.generateShifts as jest.Mock).mockResolvedValue([mockDraftInstance]);
render(<Schedules />);
await waitFor(() => expect(screen.getByText('Generate')).toBeInTheDocument());
fireEvent.click(screen.getByText('Generate'));
expect(api.generateShifts).toHaveBeenCalled();
});
it('calls publishShifts on Publish click', async () => {
(api.publishShifts as jest.Mock).mockResolvedValue({ year: 2026, month: 4 });
(api.listShifts as jest.Mock)
.mockResolvedValueOnce([mockDraftInstance])
.mockResolvedValue([{ ...mockDraftInstance, status: 'published' }]);
render(<Schedules />);
await waitFor(() => expect(screen.getByText('Publish')).toBeInTheDocument());
fireEvent.click(screen.getByText('Publish'));
expect(api.publishShifts).toHaveBeenCalled();
});
it('shows Unpublish button when all shifts are published', async () => {
(api.listShifts as jest.Mock).mockResolvedValue([mockPublishedInstance]);
render(<Schedules />);
await waitFor(() => expect(screen.getByText('Unpublish')).toBeInTheDocument());
});
it('shows Edit button on each shift row', async () => {
render(<Schedules />);
await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument());
expect(screen.getByText('Edit')).toBeInTheDocument();
});
it('opens edit form when Edit is clicked', async () => {
render(<Schedules />);
await waitFor(() => expect(screen.getByText('Edit')).toBeInTheDocument());
fireEvent.click(screen.getByText('Edit'));
expect(screen.getByText(/Edit Shift/)).toBeInTheDocument();
});
it('switches to templates view', async () => {
render(<Schedules />);
await waitFor(() => expect(screen.getByText('Manage Templates')).toBeInTheDocument());
fireEvent.click(screen.getByText('Manage Templates'));
await waitFor(() => expect(screen.getByText('Shift Templates')).toBeInTheDocument());
expect(screen.getByText('Morning Shift')).toBeInTheDocument();
});
it('shows template form when New Template is clicked', async () => {
render(<Schedules />);
await waitFor(() => expect(screen.getByText('Manage Templates')).toBeInTheDocument());
fireEvent.click(screen.getByText('Manage Templates'));
await waitFor(() => expect(screen.getByText('+ New Template')).toBeInTheDocument());
fireEvent.click(screen.getByText('+ New Template'));
expect(screen.getByText('New Template')).toBeInTheDocument();
});
it('deletes a template', async () => {
(api.deleteShiftTemplate as jest.Mock).mockResolvedValue(undefined);
window.confirm = jest.fn().mockReturnValue(true);
render(<Schedules />);
await waitFor(() => expect(screen.getByText('Manage Templates')).toBeInTheDocument());
fireEvent.click(screen.getByText('Manage Templates'));
await waitFor(() => expect(screen.getAllByText('Delete')[0]).toBeInTheDocument());
fireEvent.click(screen.getAllByText('Delete')[0]);
expect(api.deleteShiftTemplate).toHaveBeenCalledWith(1);
});
});

View File

@@ -1,105 +1,471 @@
import React, { useEffect, useState, FormEvent } from 'react';
import { api, Schedule } from '../api';
import {
api,
ShiftTemplate,
ShiftInstance,
CreateShiftTemplateInput,
TemplateRole,
OPERATIONAL_ROLES,
} from '../api';
import { useAuth } from '../auth';
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
function currentYearMonth() {
const now = new Date();
return { year: now.getFullYear(), month: now.getMonth() + 1 };
}
// ---------------------------------------------------------------------------
// Template form
// ---------------------------------------------------------------------------
function blankTemplate(): CreateShiftTemplateInput {
return {
name: '',
day_of_week: 1,
start_time: '09:00:00',
end_time: '17:00:00',
min_capacity: 1,
max_capacity: 5,
roles: [],
volunteer_ids: [],
};
}
interface TemplateFormProps {
initial?: Partial<CreateShiftTemplateInput>;
onSave: (data: CreateShiftTemplateInput) => Promise<void>;
onCancel: () => void;
title: string;
}
function TemplateForm({ initial, onSave, onCancel, title }: TemplateFormProps) {
const [form, setForm] = useState<CreateShiftTemplateInput>({ ...blankTemplate(), ...initial });
const [roleRow, setRoleRow] = useState<TemplateRole>({ role_name: '', count: 1 });
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
setSaving(true);
try {
await onSave(form);
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
}
function addRole() {
if (!roleRow.role_name) return;
setForm(f => ({ ...f, roles: [...(f.roles ?? []), { ...roleRow }] }));
setRoleRow({ role_name: '', count: 1 });
}
function removeRole(idx: number) {
setForm(f => ({ ...f, roles: (f.roles ?? []).filter((_, i) => i !== idx) }));
}
return (
<form className="card" onSubmit={handleSubmit}>
<h3>{title}</h3>
{error && <p className="error">{error}</p>}
<label>
Name
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} required />
</label>
<label>
Day of Week
<select value={form.day_of_week} onChange={e => setForm(f => ({ ...f, day_of_week: Number(e.target.value) }))}>
{DAY_NAMES.map((d, i) => <option key={i} value={i}>{d}</option>)}
</select>
</label>
<label>
Start Time
<input type="time" value={form.start_time.slice(0, 5)}
onChange={e => setForm(f => ({ ...f, start_time: e.target.value + ':00' }))} required />
</label>
<label>
End Time
<input type="time" value={form.end_time.slice(0, 5)}
onChange={e => setForm(f => ({ ...f, end_time: e.target.value + ':00' }))} required />
</label>
<label>
Min Capacity
<input type="number" min={1} value={form.min_capacity}
onChange={e => setForm(f => ({ ...f, min_capacity: Number(e.target.value) }))} required />
</label>
<label>
Max Capacity
<input type="number" min={1} value={form.max_capacity}
onChange={e => setForm(f => ({ ...f, max_capacity: Number(e.target.value) }))} required />
</label>
<fieldset>
<legend>Role Requirements</legend>
{(form.roles ?? []).map((r, i) => (
<div key={i} style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.25rem' }}>
<span>{r.count}× {r.role_name}</span>
<button type="button" className="btn-danger btn-small" onClick={() => removeRole(i)}>Remove</button>
</div>
))}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end' }}>
<label style={{ flex: 1 }}>
Role
<select value={roleRow.role_name} onChange={e => setRoleRow(r => ({ ...r, role_name: e.target.value }))}>
<option value="">Select role</option>
{OPERATIONAL_ROLES.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</label>
<label>
Count
<input type="number" min={1} value={roleRow.count} style={{ width: '4rem' }}
onChange={e => setRoleRow(r => ({ ...r, count: Number(e.target.value) }))} />
</label>
<button type="button" onClick={addRole}>Add Role</button>
</div>
</fieldset>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
<button type="button" onClick={onCancel}>Cancel</button>
</div>
</form>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
type View = 'shifts' | 'templates';
export default function Schedules() {
const { role } = useAuth();
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [view, setView] = useState<View>('shifts');
// Month navigation
const init = currentYearMonth();
const [year, setYear] = useState(init.year);
const [month, setMonth] = useState(init.month);
// Data
const [instances, setInstances] = useState<ShiftInstance[]>([]);
const [templates, setTemplates] = useState<ShiftTemplate[]>([]);
const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ title: '', starts_at: '', ends_at: '', notes: '' });
// UI state
const [showTemplateForm, setShowTemplateForm] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<ShiftTemplate | null>(null);
const [editingInstance, setEditingInstance] = useState<ShiftInstance | null>(null);
const [editVolIds, setEditVolIds] = useState('');
const [editMinCap, setEditMinCap] = useState('');
const [editMaxCap, setEditMaxCap] = useState('');
useEffect(() => {
api.listSchedules().then(setSchedules).catch(() => setError('Could not load schedules.'));
}, []);
if (view === 'shifts') {
api.listShifts(year, month)
.then(setInstances)
.catch(() => setError('Could not load shifts.'));
} else {
api.listShiftTemplates()
.then(setTemplates)
.catch(() => setError('Could not load templates.'));
}
}, [view, year, month]);
async function handleCreate(e: FormEvent) {
e.preventDefault();
function prevMonth() {
if (month === 1) { setYear(y => y - 1); setMonth(12); }
else setMonth(m => m - 1);
}
function nextMonth() {
if (month === 12) { setYear(y => y + 1); setMonth(1); }
else setMonth(m => m + 1);
}
async function handleGenerate() {
setError('');
try {
const sc = await api.createSchedule(form);
setSchedules(prev => [...prev, sc]);
setForm({ title: '', starts_at: '', ends_at: '', notes: '' });
setShowForm(false);
const newInstances = await api.generateShifts(year, month);
setInstances(newInstances);
} catch (err: any) {
setError(err.message);
}
}
async function handleDelete(id: number) {
if (!window.confirm('Delete this schedule?')) return;
async function handlePublish() {
setError('');
try {
await api.deleteSchedule(id);
setSchedules(prev => prev.filter(s => s.id !== id));
await api.publishShifts(year, month);
const updated = await api.listShifts(year, month);
setInstances(updated);
} catch (err: any) {
setError(err.message);
}
}
async function handleUnpublish() {
if (!window.confirm(`Unpublish the ${MONTH_NAMES[month - 1]} ${year} schedule?`)) return;
setError('');
try {
await api.unpublishShifts(year, month);
const updated = await api.listShifts(year, month);
setInstances(updated);
} catch (err: any) {
setError(err.message);
}
}
async function handleConfirm(id: number) {
setError('');
try {
await api.confirmShift(id);
const updated = await api.listShifts(year, month);
setInstances(updated);
} catch (err: any) {
setError(err.message);
}
}
async function handleCreateTemplate(data: CreateShiftTemplateInput) {
const t = await api.createShiftTemplate(data);
setTemplates(prev => [...prev, t]);
setShowTemplateForm(false);
}
async function handleUpdateTemplate(data: CreateShiftTemplateInput) {
if (!editingTemplate) return;
const t = await api.updateShiftTemplate(editingTemplate.id, data);
setTemplates(prev => prev.map(x => (x.id === t.id ? t : x)));
setEditingTemplate(null);
}
async function handleDeleteTemplate(id: number) {
if (!window.confirm('Delete this template? Existing shifts will not be affected.')) return;
setError('');
try {
await api.deleteShiftTemplate(id);
setTemplates(prev => prev.filter(t => t.id !== id));
} catch (err: any) {
setError(err.message);
}
}
function openEditInstance(inst: ShiftInstance) {
setEditingInstance(inst);
setEditVolIds(inst.volunteers.map(v => v.volunteer_id).join(','));
setEditMinCap(String(inst.min_capacity));
setEditMaxCap(String(inst.max_capacity));
}
async function handleUpdateInstance(e: FormEvent) {
e.preventDefault();
if (!editingInstance) return;
setError('');
try {
const volIds = editVolIds.trim()
? editVolIds.split(',').map(s => Number(s.trim())).filter(Boolean)
: [];
const updated = await api.updateShift(editingInstance.id, {
volunteer_ids: volIds,
min_capacity: Number(editMinCap),
max_capacity: Number(editMaxCap),
});
setInstances(prev => prev.map(i => (i.id === updated.id ? updated : i)));
setEditingInstance(null);
} catch (err: any) {
setError(err.message);
}
}
const allPublished = instances.length > 0 && instances.every(i => i.status === 'published');
const hasDraft = instances.some(i => i.status === 'draft');
return (
<div className="page">
<div className="page-header">
<h2>Schedules</h2>
{role === 'admin' && (
<button onClick={() => setShowForm(v => !v)}>
{showForm ? 'Cancel' : 'Add Shift'}
</button>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button onClick={() => setView(v => v === 'shifts' ? 'templates' : 'shifts')}>
{view === 'shifts' ? 'Manage Templates' : 'View Shifts'}
</button>
</div>
)}
</div>
{error && <p className="error">{error}</p>}
{showForm && (
<form className="card" onSubmit={handleCreate}>
<h3>New Shift</h3>
<label>
Title
<input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} required />
</label>
<label>
Starts At
<input type="datetime-local" value={form.starts_at} onChange={e => setForm(f => ({ ...f, starts_at: e.target.value }))} required />
</label>
<label>
Ends At
<input type="datetime-local" value={form.ends_at} onChange={e => setForm(f => ({ ...f, ends_at: e.target.value }))} required />
</label>
<label>
Notes
<textarea value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} />
</label>
<button type="submit">Create</button>
</form>
{/* ---- Templates view ---- */}
{view === 'templates' && role === 'admin' && (
<>
<div className="page-header" style={{ marginBottom: '1rem' }}>
<h3>Shift Templates</h3>
<button onClick={() => { setShowTemplateForm(true); setEditingTemplate(null); }}>
+ New Template
</button>
</div>
{showTemplateForm && !editingTemplate && (
<TemplateForm
title="New Template"
onSave={handleCreateTemplate}
onCancel={() => setShowTemplateForm(false)}
/>
)}
{editingTemplate && (
<TemplateForm
title={`Edit: ${editingTemplate.name}`}
initial={editingTemplate}
onSave={handleUpdateTemplate}
onCancel={() => setEditingTemplate(null)}
/>
)}
{templates.length === 0 ? (
<p>No templates yet.</p>
) : (
<table>
<thead>
<tr>
<th>Name</th>
<th>Day</th>
<th>Time</th>
<th>Capacity</th>
<th>Roles</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{templates.map(t => (
<tr key={t.id}>
<td>{t.name}</td>
<td>{DAY_NAMES[t.day_of_week]}</td>
<td>{t.start_time.slice(0, 5)}{t.end_time.slice(0, 5)}</td>
<td>{t.min_capacity}{t.max_capacity}</td>
<td>{(t.roles ?? []).map(r => `${r.count}× ${r.role_name}`).join(', ') || '—'}</td>
<td>
<button className="btn-small" onClick={() => { setEditingTemplate(t); setShowTemplateForm(false); }}>Edit</button>
{' '}
<button className="btn-danger btn-small" onClick={() => handleDeleteTemplate(t.id)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
{schedules.length === 0 ? (
<p>No schedules found.</p>
) : (
<table>
<thead>
<tr>
<th>Title</th>
<th>Starts</th>
<th>Ends</th>
<th>Notes</th>
{role === 'admin' && <th>Actions</th>}
</tr>
</thead>
<tbody>
{schedules.map(s => (
<tr key={s.id}>
<td>{s.title}</td>
<td>{new Date(s.starts_at).toLocaleString()}</td>
<td>{new Date(s.ends_at).toLocaleString()}</td>
<td>{s.notes ?? '—'}</td>
{role === 'admin' && (
<td>
<button className="btn-danger btn-small" onClick={() => handleDelete(s.id)}>Delete</button>
</td>
{/* ---- Shifts view ---- */}
{view === 'shifts' && (
<>
{/* Month navigation */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
<button onClick={prevMonth}></button>
<strong>{MONTH_NAMES[month - 1]} {year}</strong>
<button onClick={nextMonth}></button>
{role === 'admin' && (
<div style={{ display: 'flex', gap: '0.5rem', marginLeft: 'auto' }}>
<button onClick={handleGenerate}>Generate</button>
{hasDraft && <button onClick={handlePublish}>Publish</button>}
{allPublished && (
<button className="btn-danger" onClick={handleUnpublish}>Unpublish</button>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Instance edit form */}
{editingInstance && (
<form className="card" onSubmit={handleUpdateInstance}>
<h3>Edit Shift: {editingInstance.name} {editingInstance.date}</h3>
<label>
Volunteer IDs (comma-separated)
<input value={editVolIds} onChange={e => setEditVolIds(e.target.value)} />
</label>
<label>
Min Capacity
<input type="number" min={1} value={editMinCap}
onChange={e => setEditMinCap(e.target.value)} />
</label>
<label>
Max Capacity
<input type="number" min={1} value={editMaxCap}
onChange={e => setEditMaxCap(e.target.value)} />
</label>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button type="submit">Save</button>
<button type="button" onClick={() => setEditingInstance(null)}>Cancel</button>
</div>
</form>
)}
{instances.length === 0 ? (
<p>No shifts for {MONTH_NAMES[month - 1]} {year}.{role === 'admin' && ' Use Generate to create shifts from templates.'}</p>
) : (
<table>
<thead>
<tr>
<th>Date</th>
<th>Shift</th>
<th>Time</th>
<th>Status</th>
<th>Volunteers</th>
<th>Capacity</th>
{role === 'admin' && <th>Actions</th>}
</tr>
</thead>
<tbody>
{instances.map(inst => {
const myAssignment = inst.volunteers.find(() => true); // for volunteers, API already filters
const confirmed = inst.volunteers.some(v => v.confirmed);
return (
<tr key={inst.id}>
<td>{inst.date}</td>
<td>{inst.name}</td>
<td>{inst.start_time.slice(0, 5)}{inst.end_time.slice(0, 5)}</td>
<td>
<span className={inst.status === 'published' ? 'badge-success' : 'badge-neutral'}>
{inst.status}
</span>
</td>
<td>
{inst.volunteers.length === 0 ? '—' : inst.volunteers.map(v => (
<span key={v.volunteer_id} title={v.confirmed ? 'Confirmed' : 'Unconfirmed'}>
{v.name}{v.confirmed ? ' ✓' : ' ⚠'}
{' '}
</span>
))}
</td>
<td>{inst.volunteers.length}/{inst.max_capacity}</td>
{role === 'admin' && (
<td>
<button className="btn-small" onClick={() => openEditInstance(inst)}>Edit</button>
</td>
)}
{role !== 'admin' && inst.status === 'published' && !confirmed && myAssignment && (
<td>
<button className="btn-small" onClick={() => handleConfirm(inst.id)}>Confirm</button>
</td>
)}
</tr>
);
})}
</tbody>
</table>
)}
</>
)}
</div>
);