> ## Documentation Index
> Fetch the complete documentation index at: https://docs.virtuous.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Read a Project's Schedule

> The three-endpoint pattern for reading Project scheduling data — embedded all_dates on the Project detail, the daily /projects/today feed, and the per-Date /projects/date/{id} detail. How to combine them efficiently.

The Volunteer API exposes Project scheduling data through three different endpoints, each suited to a different purpose. Choosing the right one for a workflow can be the difference between an integration that scales gracefully and one that drowns in unnecessary requests.

This workflow page walks through the three endpoints, when to use each, how to combine them, and the practical patterns for common scheduling integrations — calendar views, daily dashboards, participant rosters, and external-calendar sync.

If you haven't yet, skim the [Projects and Project Dates concept page](/volunteer/concepts/projects-and-project-dates) for the Project vs. Project Date distinction and the field shapes.

## When to use this workflow

| Scenario                                      | This workflow fits                                                               |
| --------------------------------------------- | -------------------------------------------------------------------------------- |
| Build a calendar view of a Project's schedule | ✓ Use `GET /projects/{id}` for `all_dates`                                       |
| Show "what's happening today" across the org  | ✓ Use `GET /projects/today`                                                      |
| Show participants for a specific shift        | ✓ Use `GET /projects/date/{id}`                                                  |
| Generate an iCal feed of a Project's schedule | ✓ Same as calendar view                                                          |
| Daily dashboard for check-in staff            | ✓ `/projects/today` then drill in per-Date                                       |
| Find Projects with available slots            | Use `GET /projects` with date filters; per-slot capacity may need detail fetches |

***

## The three endpoints

| Endpoint                  | What it returns                                              | Use case                          |
| ------------------------- | ------------------------------------------------------------ | --------------------------------- |
| `GET /projects/{id}`      | A single Project with `all_dates[]` and `next_date` embedded | Full schedule for one Project     |
| `GET /projects/today`     | All Project Dates happening today (across all Projects)      | Daily operational dashboards      |
| `GET /projects/date/{id}` | A single Project Date with its participants                  | Detail view of one specific shift |

The right choice depends on what you have and what you want. The decision tree:

```mermaid theme={null}
graph TD
  Start([What do you want?])
  KnowProject{Do you know the<br/>Project ID?}
  WantToday{Do you want<br/>today's dates?}
  WantParticipants{Do you need<br/>participants?}

  GetProjectDetail["GET /projects/&#123;id&#125;<br/>(returns all_dates)"]
  GetToday["GET /projects/today<br/>(returns today's dates)"]
  GetProjectDate["GET /projects/date/&#123;id&#125;<br/>(returns participants)"]

  Start --> WantToday
  WantToday -->|yes| GetToday
  WantToday -->|no| KnowProject
  KnowProject -->|yes| WantParticipants
  WantParticipants -->|"no (just schedule)"| GetProjectDetail
  WantParticipants -->|"yes (per Date)"| GetProjectDate
  KnowProject -->|no| ListProjects["GET /projects<br/>(filter, then detail)"]
```

***

## Scenario 1: Get a Project's full schedule

For a known Project, the most efficient way to get its full schedule is `GET /projects/{id}` — the response includes `all_dates` and `next_date` embedded, so you don't need separate Project Date fetches.

```bash cURL theme={null}
curl https://api.vomo.org/v1/projects/789 \
  -H "Authorization: Bearer $VOMO_API_TOKEN"
```

A successful response (relevant fields):

```json theme={null}
{
  "data": {
    "id": 789,
    "name": "Saturday Food Bank Shift",
    "all_dates": [
      {
        "id": 1011,
        "starts_at": "2025-04-19T14:00:00Z",
        "ends_at": "2025-04-19T18:00:00Z",
        "participant_count": 15
      },
      {
        "id": 1012,
        "starts_at": "2025-04-26T14:00:00Z",
        "ends_at": "2025-04-26T18:00:00Z",
        "participant_count": 12
      }
    ],
    "next_date": {
      "id": 1011,
      "starts_at": "2025-04-19T14:00:00Z",
      "ends_at": "2025-04-19T18:00:00Z",
      "participant_count": 15
    }
  }
}
```

### What you get without extra calls

`all_dates[]` includes for each Project Date:

| Field               | Description                     |
| ------------------- | ------------------------------- |
| `id`                | The Project Date's stable ID    |
| `starts_at`         | ISO 8601 start time             |
| `ends_at`           | ISO 8601 end time               |
| `participant_count` | The number of confirmed signups |

This is enough for calendar views, scheduling displays, and most "show the schedule" workflows. You don't need to call `GET /projects/date/{id}` unless you also need the per-Date participants.

### When you need more than `all_dates` provides

`all_dates` doesn't include:

* **Per-Date participants** (which volunteers signed up for which shift) — requires `GET /projects/date/{id}`
* **Per-Date custom data** (if the customer added Project-Date-specific fields)

For workflows that need participants per Date, see [Scenario 4: Get participants for a specific shift](#scenario-4-get-participants-for-a-specific-shift).

### `next_date` for "next upcoming shift" displays

The `next_date` field is convenient for UIs showing "your next opportunity" — it's the chronologically-nearest future Project Date, pre-computed:

```javascript JavaScript theme={null}
async function getNextShiftForProject(projectId) {
  const project = await getProjectDetail(projectId);
  return project?.next_date ?? null;
}

// Usage
const next = await getNextShiftForProject(789);
if (next) {
  console.log(`Next shift: ${next.starts_at} (${next.participant_count} volunteers signed up)`);
}
```

For a "what's coming up?" homepage card, this is the single right field.

***

## Scenario 2: Daily operational dashboard

For check-in staff or operations dashboards showing "what's happening today":

```bash cURL theme={null}
curl https://api.vomo.org/v1/projects/today \
  -H "Authorization: Bearer $VOMO_API_TOKEN"
```

Returns a list of `HappeningResource` objects — all Project Dates scheduled for today, across all of the organization's Projects:

```json theme={null}
{
  "data": [
    {
      "type": "happening",
      "id": 1011,
      "starts_at": "2025-04-19T14:00:00Z",
      "ends_at": "2025-04-19T18:00:00Z",
      "participant_count": 15
    },
    {
      "type": "happening",
      "id": 1015,
      "starts_at": "2025-04-19T09:00:00Z",
      "ends_at": "2025-04-19T12:00:00Z",
      "participant_count": 8
    }
  ]
}
```

### What this is good for

| Workflow                          | How `/projects/today` fits                  |
| --------------------------------- | ------------------------------------------- |
| Daily summary email to admin team | Pull at start of day; format the list       |
| Check-in dashboard                | Real-time view of the day's shifts          |
| Operations status display         | Show signup totals across all active shifts |
| Quick "find my shift" UI          | Volunteer selects from today's options      |

### Note: today's *Project Dates*, not Projects

The response is Project Dates (Happenings), not Projects. A single Project running a morning and afternoon shift today appears as two entries. To roll up to Projects:

```javascript JavaScript theme={null}
async function getTodayGroupedByProject() {
  const response = await fetch(
    'https://api.vomo.org/v1/projects/today',
    { headers: { Authorization: `Bearer ${token}` } }
  );
  const today = await response.json();

  // Each happening doesn't include the Project name directly — to display nicely,
  // call GET /projects/date/{id} per Happening to get the project_name
  const enriched = await Promise.all(
    today.data.map(async (happening) => {
      const date = await getProjectDate(happening.id);
      return {
        ...happening,
        projectName: date?.data?.project_name,
        projectId: date?.data?.project_id,
      };
    })
  );

  // Group by Project
  const byProject = new Map();
  for (const item of enriched) {
    if (!byProject.has(item.projectId)) {
      byProject.set(item.projectId, {
        projectName: item.projectName,
        projectId: item.projectId,
        shifts: [],
      });
    }
    byProject.get(item.projectId).shifts.push({
      id: item.id,
      starts_at: item.starts_at,
      ends_at: item.ends_at,
      participant_count: item.participant_count,
    });
  }

  return Array.from(byProject.values());
}
```

This produces a Project-keyed view: "Saturday Food Bank Shift has two shifts today (9-12 and 2-6)."

### The cost of "today" + drill-down

The pattern above is N+1 — one `/projects/today` call plus one `/projects/date/{id}` per Happening. For organizations with many simultaneous shifts (10-20+ on a given day), this can be expensive. Caching the per-Date detail helps:

```javascript JavaScript theme={null}
class TodayCache {
  constructor({ ttlMs = 5 * 60 * 1000 }) {
    this.ttlMs = ttlMs;
    this.dateDetailCache = new Map(); // dateId → { detail, cachedAt }
  }

  async getDateDetail(dateId) {
    const cached = this.dateDetailCache.get(dateId);
    if (cached && Date.now() - cached.cachedAt < this.ttlMs) {
      return cached.detail;
    }

    const detail = await getProjectDate(dateId);
    this.dateDetailCache.set(dateId, { detail, cachedAt: Date.now() });
    return detail;
  }
}
```

5-minute cache TTL is reasonable for "today" workflows — participants change but not so fast that 5-minute lag matters for most dashboard use cases.

***

## Scenario 3: Find Projects with available slots

For partner-built signup interfaces, find Projects with upcoming Dates that have capacity:

```javascript JavaScript theme={null}
async function findProjectsWithCapacity(maxParticipants = 50) {
  // 1. Get upcoming, active Projects
  const now = new Date().toISOString();
  const futureCutoff = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();

  const params = new URLSearchParams({
    active: 'true',
    published: 'true',
    dates_after: now,
    dates_before: futureCutoff,
  });

  const upcomingProjects = await paginate(
    `https://api.vomo.org/v1/projects?${params}`
  );

  // 2. For each Project, check the next Date's capacity
  const available = [];
  for (const projectSummary of upcomingProjects) {
    const project = await getProjectDetail(projectSummary.id);
    const next = project?.next_date;

    if (next && next.participant_count < maxParticipants) {
      available.push({
        project,
        nextShift: next,
        availableSpots: maxParticipants - next.participant_count,
      });
    }
  }

  return available;
}
```

The pattern uses `next_date` to avoid fetching all dates for each Project — efficient for the common "find a shift to volunteer for" use case.

For more sophisticated filtering (e.g., "find shifts with at least 5 open spots"), iterate `all_dates`:

```javascript JavaScript theme={null}
async function findShiftsWithCapacity(projectId, openSlotsNeeded) {
  const project = await getProjectDetail(projectId);
  const maxCapacity = 50; // From elsewhere — maybe configured per Project

  return (project?.all_dates ?? [])
    .filter((d) => maxCapacity - d.participant_count >= openSlotsNeeded)
    .filter((d) => new Date(d.starts_at) > new Date()); // Future only
}
```

***

## Scenario 4: Get participants for a specific shift

When you need to know who's signed up for a specific Project Date:

```bash cURL theme={null}
curl https://api.vomo.org/v1/projects/date/1011 \
  -H "Authorization: Bearer $VOMO_API_TOKEN"
```

Returns the Project Date with its participants embedded:

```json theme={null}
{
  "data": {
    "type": "project_date",
    "id": "5678",
    "project_name": "Saturday Food Bank Shift",
    "starts_at": "2025-04-19T14:00:00Z",
    "ends_at": "2025-04-19T18:00:00Z",
    "participant_count": 15,
    "participants": [
      {
        "type": "user",
        "id": 123456,
        "email": "bruce@wayne.example",
        "guests": 0,
        "checked_in_at": null,
        "checked_out_at": null,
        "signed_up_at": "2025-04-01T10:30:00Z",
        "hours": "0.00",
        "verified": 0,
        "role": "Volunteer",
        "project_id": 789,
        "project_date_id": 1011
      }
    ]
  }
}
```

<Warning>
  **⚠️ Spec gap (audit #4):** This endpoint uses empty `schema: {}` in the spec — the response shape is documented only through the inline example. Build parsers from observed live responses.
</Warning>

<Warning>
  **⚠️ Spec gap (audit #34):** The endpoint's path `/projects/date/{id}` is unconventional (verb-like singular noun). A future revision may rename it to `/projects/{projectId}/dates/{dateId}`. Until then, the current path is what works.
</Warning>

### The participants embedded here

Each participant entry has:

| Field             | Description                                                            |
| ----------------- | ---------------------------------------------------------------------- |
| `id`              | The participating User's ID                                            |
| `email`           | The User's email                                                       |
| `guests`          | Number of guests they brought                                          |
| `checked_in_at`   | When they arrived (null if not checked in)                             |
| `checked_out_at`  | When they left (null if not checked out)                               |
| `signed_up_at`    | When they registered                                                   |
| `hours`           | Total hours volunteered (fractional, returned as string per audit #40) |
| `verified`        | Whether the organizer confirmed attendance (0 or 1)                    |
| `role`            | Their role (e.g., "Volunteer", "Team Lead")                            |
| `project_id`      | Back-reference to the Project                                          |
| `project_date_id` | Back-reference to this Project Date                                    |

This shape includes the Participation data inline (rather than requiring a separate fetch through the User detail). It's the cleanest path to "who's signed up for this shift?"

### Parsing the participants defensively

```javascript JavaScript theme={null}
function parseProjectDateParticipants(raw) {
  return (raw.participants ?? []).map((p) => ({
    userId: p.id,
    email: p.email,
    guests: p.guests ?? 0,
    checkedInAt: p.checked_in_at ? new Date(p.checked_in_at) : null,
    checkedOutAt: p.checked_out_at ? new Date(p.checked_out_at) : null,
    signedUpAt: p.signed_up_at ? new Date(p.signed_up_at) : null,
    // hours: spec says integer but live returns string fractional value
    hours: typeof p.hours === 'string' ? parseFloat(p.hours) : (p.hours ?? 0),
    verified: p.verified === 1 || p.verified === true,
    role: p.role ?? 'Volunteer',
    projectId: p.project_id,
    projectDateId: p.project_date_id,
  }));
}
```

The defensive parsing handles:

* `hours` returning as a string (`"4.00"`) instead of integer per audit #40
* `verified` returning as `0` or `1` integer (treat as boolean)
* Missing optional fields

***

## Scenario 5: Build an iCal feed for a Project

A common partner workflow: generate an iCalendar (`.ics`) feed of a Project's schedule for the customer's external calendar systems:

```javascript JavaScript theme={null}
async function generateProjectIcal(projectId) {
  const project = await getProjectDetail(projectId);
  if (!project) throw new Error(`Project ${projectId} not found`);

  const events = (project.all_dates ?? []).map((date) => ({
    uid: `vomo-project-date-${date.id}@vomo.org`,
    summary: project.name,
    description: project.description,
    location: project.address?.formatted_address ?? '',
    start: new Date(date.starts_at),
    end: new Date(date.ends_at),
    url: project.url,
  }));

  return buildIcalString(events);
}

function buildIcalString(events) {
  const lines = [
    'BEGIN:VCALENDAR',
    'VERSION:2.0',
    'PRODID:-//Volunteer Integration//EN',
  ];

  for (const event of events) {
    lines.push('BEGIN:VEVENT');
    lines.push(`UID:${event.uid}`);
    lines.push(`SUMMARY:${escapeIcal(event.summary)}`);
    lines.push(`DESCRIPTION:${escapeIcal(event.description)}`);
    lines.push(`LOCATION:${escapeIcal(event.location)}`);
    lines.push(`DTSTART:${formatIcalDate(event.start)}`);
    lines.push(`DTEND:${formatIcalDate(event.end)}`);
    if (event.url) lines.push(`URL:${event.url}`);
    lines.push('END:VEVENT');
  }

  lines.push('END:VCALENDAR');
  return lines.join('\r\n');
}

function formatIcalDate(date) {
  return date.toISOString().replace(/[-:.]/g, '').replace('000Z', 'Z');
}

function escapeIcal(s) {
  return (s ?? '').replace(/[\\,;]/g, '\\$&').replace(/\n/g, '\\n');
}
```

The one `GET /projects/{id}` call gives you the full schedule needed for the feed — no per-Date fetches required.

For workflows generating iCal feeds for many Projects simultaneously, cache aggressively — Project schedules change infrequently.

***

## Scenario 6: Multi-Project schedule view

For "all upcoming shifts" displays spanning multiple Projects:

```javascript JavaScript theme={null}
async function getAllUpcomingShifts(maxDaysOut = 14) {
  const now = new Date();
  const cutoff = new Date(now.getTime() + maxDaysOut * 24 * 60 * 60 * 1000);

  // 1. Get Projects with dates in the window
  const params = new URLSearchParams({
    active: 'true',
    published: 'true',
    dates_after: now.toISOString(),
    dates_before: cutoff.toISOString(),
  });

  const projects = await paginate(`https://api.vomo.org/v1/projects?${params}`);

  // 2. For each Project, fetch detail to get all_dates (parallelizable)
  const allShifts = [];
  for (const projectSummary of projects) {
    const project = await getProjectDetail(projectSummary.id);
    const futureShifts = (project?.all_dates ?? []).filter((d) => {
      const dStart = new Date(d.starts_at);
      return dStart >= now && dStart <= cutoff;
    });

    for (const shift of futureShifts) {
      allShifts.push({
        projectId: project.id,
        projectName: project.name,
        shiftId: shift.id,
        startsAt: new Date(shift.starts_at),
        endsAt: new Date(shift.ends_at),
        participantCount: shift.participant_count,
      });
    }
  }

  // 3. Sort chronologically
  return allShifts.sort((a, b) => a.startsAt - b.startsAt);
}
```

This is N+1 (one Project list + one detail per Project). For accounts with many Projects, throttle and cache:

```javascript JavaScript theme={null}
async function getAllUpcomingShiftsCached(customerId, maxDaysOut = 14) {
  const cacheKey = `upcoming_shifts_${customerId}_${maxDaysOut}`;
  const cached = await cache.get(cacheKey);
  if (cached && Date.now() - cached.fetchedAt < 5 * 60 * 1000) {
    return cached.shifts;
  }

  const shifts = await getAllUpcomingShifts(maxDaysOut);
  await cache.set(cacheKey, { shifts, fetchedAt: Date.now() });
  return shifts;
}
```

A 5-minute cache balances freshness with API cost — schedules don't change so quickly that 5-minute lag matters for most "what's coming up?" displays.

***

## Choosing the right endpoint

A summary table for quick decisions:

| You want...                             | Use...                                                             | Why                                             |
| --------------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------- |
| A Project's full schedule               | `GET /projects/{id}`                                               | `all_dates` is embedded — no extra calls needed |
| The next upcoming shift for a Project   | `GET /projects/{id}` then `next_date`                              | Same                                            |
| Today's active shifts across the org    | `GET /projects/today`                                              | Single call, ready-to-display                   |
| Today's shifts grouped by Project       | `GET /projects/today` then `GET /projects/date/{id}` per Happening | Get project\_name from the Date detail          |
| Participants signed up for one shift    | `GET /projects/date/{id}`                                          | Participants embedded                           |
| Find Projects with available capacity   | `GET /projects` (filtered) + `next_date`                           | Use the embedded `next_date.participant_count`  |
| All upcoming shifts across all Projects | `GET /projects` (filtered) + `GET /projects/{id}` per              | N+1 — cache aggressively                        |
| Volunteer hours summary for a User      | `GET /users/{id}` — `participations` is embedded                   | No Project Date fetches needed                  |

The pattern is roughly: **use the most-embedded endpoint that has what you need, then drill down only when necessary.** The Project detail endpoint is the workhorse for most workflows.

***

## What can't be done via the API

| Capability                                                | Status                                                                    |
| --------------------------------------------------------- | ------------------------------------------------------------------------- |
| Create a Project Date independently of its Project        | Not exposed — schedule is set via the Project's `dates` array on POST/PUT |
| Modify a Project Date's time without updating the Project | Not exposed — use full PUT on `/projects/{id}`                            |
| Cancel an individual Project Date                         | Not exposed — must remove from the Project's `dates` array                |
| Sign someone up for a Project Date                        | Not exposed — admin UI only                                               |
| Mark a participant as checked in                          | Not exposed — admin UI only                                               |
| Update participant hours after the fact                   | Not exposed                                                               |
| List all Project Dates without going through a Project    | Not exposed — no top-level `/project-dates` endpoint                      |

See [Understand Write Limitations](/volunteer/workflows/understand-write-limitations).

***

## Where to go next

<CardGroup cols={2}>
  <Card title="Create or Update a Project" icon="briefcase" href="/volunteer/workflows/create-or-update-a-project">
    The companion write workflow — modifying the schedule is done by updating the Project.
  </Card>

  <Card title="Inspect Form Completions" icon="clipboard-list" href="/volunteer/workflows/inspect-form-completions">
    The next supplementary workflow.
  </Card>

  <Card title="Projects and Project Dates" icon="calendar-days" href="/volunteer/concepts/projects-and-project-dates">
    The reference page for the field shapes used here.
  </Card>

  <Card title="API Performance Tips" icon="gauge" href="/volunteer/best-practices/api-performance-tips">
    The caching patterns that make multi-Project schedule reads scale.
  </Card>
</CardGroup>
