Persist schedules view and month in the URL
All checks were successful
CI / Go tests & lint (push) Successful in 9s
CI / Frontend tests & type-check (push) Successful in 41s
CI / Go tests & lint (pull_request) Successful in 10s
CI / Frontend tests & type-check (pull_request) Successful in 41s

- /schedules?year=2026&month=4 for the shifts view
- /schedules/templates for the templates view
- Month navigation updates search params via useNavigate
- Reloading or sharing a URL restores the exact view

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 19:34:30 -03:00
parent c78a35377a
commit 9473ce24bc
3 changed files with 72 additions and 42 deletions

View File

@@ -36,6 +36,7 @@ function ProtectedLayout() {
<main>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/schedules/templates" element={<Schedules />} />
<Route path="/schedules" element={<Schedules />} />
<Route path="/timeoff" element={<TimeOff />} />
<Route path="/profile" element={<Profile />} />

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import Schedules from './Schedules';
import { api, ShiftInstance, ShiftTemplate } from '../api';
@@ -62,6 +63,14 @@ const mockTemplate: ShiftTemplate = {
updated_at: '2026-04-01T00:00:00Z',
};
function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<Schedules />
</MemoryRouter>
);
}
describe('Schedules (volunteer view)', () => {
beforeEach(() => {
useAuth.mockReturnValue({ role: 'volunteer', volunteerID: 10 });
@@ -69,7 +78,7 @@ describe('Schedules (volunteer view)', () => {
});
it('renders published shifts for a volunteer', async () => {
render(<Schedules />);
renderAt('/schedules');
await waitFor(() => {
expect(screen.getByText('Morning Shift')).toBeInTheDocument();
});
@@ -77,7 +86,7 @@ describe('Schedules (volunteer view)', () => {
});
it('does not show admin controls', async () => {
render(<Schedules />);
renderAt('/schedules');
await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument());
expect(screen.queryByText('Generate')).not.toBeInTheDocument();
expect(screen.queryByText('Publish')).not.toBeInTheDocument();
@@ -85,7 +94,7 @@ describe('Schedules (volunteer view)', () => {
});
});
describe('Schedules (admin view)', () => {
describe('Schedules (admin shifts view)', () => {
beforeEach(() => {
useAuth.mockReturnValue({ role: 'admin', volunteerID: 1 });
(api.listShifts as jest.Mock).mockResolvedValue([mockDraftInstance]);
@@ -93,7 +102,7 @@ describe('Schedules (admin view)', () => {
});
it('shows Generate and Publish buttons when drafts exist', async () => {
render(<Schedules />);
renderAt('/schedules');
await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument());
expect(screen.getByText('Generate')).toBeInTheDocument();
expect(screen.getByText('Publish')).toBeInTheDocument();
@@ -101,7 +110,7 @@ describe('Schedules (admin view)', () => {
it('calls generateShifts on Generate click', async () => {
(api.generateShifts as jest.Mock).mockResolvedValue([mockDraftInstance]);
render(<Schedules />);
renderAt('/schedules');
await waitFor(() => expect(screen.getByText('Generate')).toBeInTheDocument());
fireEvent.click(screen.getByText('Generate'));
expect(api.generateShifts).toHaveBeenCalled();
@@ -113,7 +122,7 @@ describe('Schedules (admin view)', () => {
.mockResolvedValueOnce([mockDraftInstance])
.mockResolvedValue([{ ...mockDraftInstance, status: 'published' }]);
render(<Schedules />);
renderAt('/schedules');
await waitFor(() => expect(screen.getByText('Publish')).toBeInTheDocument());
fireEvent.click(screen.getByText('Publish'));
expect(api.publishShifts).toHaveBeenCalled();
@@ -121,35 +130,43 @@ describe('Schedules (admin view)', () => {
it('shows Unpublish button when all shifts are published', async () => {
(api.listShifts as jest.Mock).mockResolvedValue([mockPublishedInstance]);
render(<Schedules />);
renderAt('/schedules');
await waitFor(() => expect(screen.getByText('Unpublish')).toBeInTheDocument());
});
it('shows Edit button on each shift row', async () => {
render(<Schedules />);
renderAt('/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 />);
renderAt('/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'));
it('reads year and month from search params', async () => {
renderAt('/schedules?year=2025&month=12');
await waitFor(() => expect(api.listShifts).toHaveBeenCalledWith(2025, 12));
});
});
describe('Schedules (admin templates view)', () => {
beforeEach(() => {
useAuth.mockReturnValue({ role: 'admin', volunteerID: 1 });
(api.listShiftTemplates as jest.Mock).mockResolvedValue([mockTemplate]);
});
it('renders templates at /schedules/templates', async () => {
renderAt('/schedules/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'));
renderAt('/schedules/templates');
await waitFor(() => expect(screen.getByText('+ New Template')).toBeInTheDocument());
fireEvent.click(screen.getByText('+ New Template'));
expect(screen.getByText('New Template')).toBeInTheDocument();
@@ -158,9 +175,7 @@ describe('Schedules (admin view)', () => {
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'));
renderAt('/schedules/templates');
await waitFor(() => expect(screen.getAllByText('Delete')[0]).toBeInTheDocument());
fireEvent.click(screen.getAllByText('Delete')[0]);
expect(api.deleteShiftTemplate).toHaveBeenCalledWith(1);

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState, FormEvent } from 'react';
import React, { useEffect, useState, FormEvent, useCallback } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import {
api,
ShiftTemplate,
@@ -145,16 +146,19 @@ function TemplateForm({ initial, onSave, onCancel, title }: TemplateFormProps) {
// Main page
// ---------------------------------------------------------------------------
type View = 'shifts' | 'templates';
export default function Schedules() {
const { role } = useAuth();
const [view, setView] = useState<View>('shifts');
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
// Month navigation
// Derive view from URL path
const isTemplatesView = location.pathname === '/schedules/templates';
// Derive year/month from search params (shifts view only)
const init = currentYearMonth();
const [year, setYear] = useState(init.year);
const [month, setMonth] = useState(init.month);
const year = Number(searchParams.get('year')) || init.year;
const month = Number(searchParams.get('month')) || init.month;
// Data
const [instances, setInstances] = useState<ShiftInstance[]>([]);
@@ -169,26 +173,37 @@ export default function Schedules() {
const [editMinCap, setEditMinCap] = useState('');
const [editMaxCap, setEditMaxCap] = useState('');
// Navigation helpers
const goToShifts = useCallback((y: number, m: number) => {
navigate(`/schedules?year=${y}&month=${m}`);
}, [navigate]);
const goToTemplates = useCallback(() => {
navigate('/schedules/templates');
}, [navigate]);
useEffect(() => {
if (view === 'shifts') {
api.listShifts(year, month)
.then(setInstances)
.catch(() => setError('Could not load shifts.'));
} else {
if (isTemplatesView) {
api.listShiftTemplates()
.then(setTemplates)
.catch(() => setError('Could not load templates.'));
} else {
api.listShifts(year, month)
.then(setInstances)
.catch(() => setError('Could not load shifts.'));
}
}, [view, year, month]);
}, [isTemplatesView, year, month]);
function prevMonth() {
if (month === 1) { setYear(y => y - 1); setMonth(12); }
else setMonth(m => m - 1);
const m = month === 1 ? 12 : month - 1;
const y = month === 1 ? year - 1 : year;
goToShifts(y, m);
}
function nextMonth() {
if (month === 12) { setYear(y => y + 1); setMonth(1); }
else setMonth(m => m + 1);
const m = month === 12 ? 1 : month + 1;
const y = month === 12 ? year + 1 : year;
goToShifts(y, m);
}
async function handleGenerate() {
@@ -295,8 +310,8 @@ export default function Schedules() {
<h2>Schedules</h2>
{role === 'admin' && (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button onClick={() => setView(v => v === 'shifts' ? 'templates' : 'shifts')}>
{view === 'shifts' ? 'Manage Templates' : 'View Shifts'}
<button onClick={() => isTemplatesView ? goToShifts(year, month) : goToTemplates()}>
{isTemplatesView ? 'View Shifts' : 'Manage Templates'}
</button>
</div>
)}
@@ -305,7 +320,7 @@ export default function Schedules() {
{error && <p className="error">{error}</p>}
{/* ---- Templates view ---- */}
{view === 'templates' && role === 'admin' && (
{isTemplatesView && role === 'admin' && (
<>
<div className="page-header" style={{ marginBottom: '1rem' }}>
<h3>Shift Templates</h3>
@@ -367,7 +382,7 @@ export default function Schedules() {
)}
{/* ---- Shifts view ---- */}
{view === 'shifts' && (
{!isTemplatesView && (
<>
{/* Month navigation */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
@@ -428,7 +443,6 @@ export default function Schedules() {
</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}>
@@ -454,7 +468,7 @@ export default function Schedules() {
<button className="btn-small" onClick={() => openEditInstance(inst)}>Edit</button>
</td>
)}
{role !== 'admin' && inst.status === 'published' && !confirmed && myAssignment && (
{role !== 'admin' && inst.status === 'published' && !confirmed && (
<td>
<button className="btn-small" onClick={() => handleConfirm(inst.id)}>Confirm</button>
</td>