What PDCAL is
PDCAL plans a K–12 district's Professional Development calendar across a July–June fiscal year, and tracks substitute-teacher coverage against configured daily allotments so gaps show up before they become a problem on the day.
Events carry a title, date/time, substitute need, facilitators, and tags. The Overview dashboard rolls every month up into subs-available / subs-needed / subs-remaining totals; Month and Week views show the day-by-day detail. Fiscal years can be imported in bulk from a Kickup opportunity export, or entered by hand.
Architecture at a glance
PDCAL runs as a Cloudflare Worker (built on Hono) backed by a Cloudflare D1 database. The browser signs in with Google, exchanges that for PDCAL's own session cookie, and talks to a JSON API for everything else — there's no offline or local-only mode.
Client
Browser
Google Sign-In, then talks to /api/* via a session cookie.
Server
Cloudflare Worker
Checks role & tag scope on every request, serves the API and static files.
Storage
Cloudflare D1
Users, fiscal years, events, tags, allotments, audit log.
The session cookie is HttpOnly, Secure, and SameSite=Strict, valid for 7 days — but every request re-checks that user's role and status in the database, so suspending someone or changing their role takes effect on their very next click, not after a token eventually expires.
Deploying & configuring PDCAL
You'll need Node, a Cloudflare account with wrangler, and a Google Cloud project.
-
Install dependencies
npm install -
Create a Google OAuth client
Google Cloud Console → Credentials → Create OAuth client ID → type Web application. Add every origin PDCAL will be served from under Authorized JavaScript origins (e.g.
http://localhost:8787for local dev, plus your production domain). That's the whole Google Cloud setup — no API key, Drive/Picker APIs, or extra scopes are needed. -
Set the client ID in both places it's read from
The same OAuth client ID must match in
wrangler.jsonc'svars.GOOGLE_CLIENT_ID(server-side verification) andpublic/index.html'swindow.PDCAL_GOOGLE_CONFIG.clientId(the Sign In button) — or sign-in fails with an audience mismatch. -
Set the two secrets
Never commit these. Locally, copy
.dev.vars.exampleto.dev.vars; in production:wrangler secret put JWT_SECRET wrangler secret put SUPER_ADMIN_EMAILSJWT_SECRETsigns session cookies — rotating it logs everyone out.SUPER_ADMIN_EMAILSis covered next. -
Run migrations, then deploy
npm run db:migrate:local # local dev npm run db:migrate:remote # production D1 npm run dev # local server npm run deploy # wrangler deploy --minify
Bootstrapping the first Super Administrator
Nobody is pre-seeded in a fresh database — SUPER_ADMIN_EMAILS is the only way in. It's a comma-separated, case-insensitive allowlist checked on every login:
SUPER_ADMIN_EMAILS=you@yourdistrict.org,other-admin@yourdistrict.org
The first time a listed address signs in with Google, PDCAL promotes it to Super Administrator automatically — no one has to grant it by hand, even if that address has never touched PDCAL before.
Everyone else's first sign-in
Anyone else gets a read-only Default session until an admin explicitly grants them a role in Manage Users — they can see the calendar, but nothing else.
Removing an email from the allowlist doesn't demote anyone
Once promoted, editing SUPER_ADMIN_EMAILS has no retroactive effect — the role only changes going forward, through Manage Users. Treat this variable as a one-time bootstrap, not an ongoing membership list.
Roles & permissions
Four roles, strictly ranked. Every role includes everything the roles below it can do.
| Role | View calendar | Create / edit / delete events | Manage users | Fiscal-year settings¹ |
|---|---|---|---|---|
| Default (Viewer) | Every fiscal year | — | — | — |
| Editor | Every fiscal year | Within assigned tag scope | — | — |
| Administrator | Every fiscal year | Within assigned tag scope | Up to Administrator | — |
| Super Administrator | Every fiscal year | Unrestricted | All users, incl. Super Admins | Yes |
¹ Substitute allotments & exemptions, tag categories, CSV import/export, Save/Load .pdcal files, and Delete All Events.
Read access is global; edit access is scoped
Every signed-in role — including a scoped Editor, and even Default/Viewer — sees every event on every day. Tag scope never hides anything from view; it only ever governs who may edit or delete a given event.
Scoped access (tag-based)
One tag category per fiscal year can be flagged Primary (see Tag Categories below). Only tags from that category can be assigned to a user as their scope, and only those tags gate edit/delete rights on an event. An Editor or Administrator can edit an event if at least one of the event's Primary-category tags is also in their own assigned scope; a Super Administrator can always edit anything.
Two scope gotchas worth knowing before you assign roles
- An Editor or Administrator with no tag scope assigned is effectively read-only. Edit rights require an actual overlap between the caller's scope and the event's tags — an empty scope can never overlap anything. Assign at least one Primary-category tag in the same step you grant the role, or the account can view the calendar but can't touch it.
- An event with no Primary-category tag can only be edited or deleted by a Super Administrator — regardless of who created it. There's no ownership fallback. If your district uses scoped Editors, make sure every event they create picks up a Primary-category tag, or they'll immediately lose the ability to edit their own event.
Guardrails
- Only a Super Administrator can create, edit, suspend, or delete another Super Administrator's account, or grant the role to anyone.
- The last remaining Super Administrator account can't be demoted, suspended, or deleted.
- No one can suspend or delete their own account.
- An Administrator can only hand out tag scope that's a subset of their own assigned scope — they can't delegate access they don't personally hold.
Managing users
Sidebar → Manage Users, visible to Administrator and Super Administrator accounts.
Adding someone
Enter their email, choose a role, and — for any role except Super Administrator — check one or more scope tags, then Add. This pre-provisions the account before they've ever signed in; it stays reserved until they sign in with that Google address for the first time.
Editing, suspending, deleting
- Edit — change role or scope tags inline, then Save. This fully replaces that user's tag scope, not adds to it.
- Suspend / Unsuspend — blocks or restores access immediately, on their very next request.
- Delete — removes the account entirely.
Why some accounts don't show up in the list
Manage Users hides plain Default/Viewer accounts, except your own row — that always shows, so you can confirm your own role and scope.
Audit log
Every user create/role-change/suspend/unsuspend/delete, and every event delete, is recorded with actor, action, target, and timestamp. There's no page for it yet — Administrators and Super Administrators can pull it directly while signed in:
GET /api/audit-logs
GET /api/audit-logs?actorUserId=12&action=user.role_change&limit=200
Fiscal-year configuration
Everything below lives in the sidebar and requires Super Administrator.
Substitute daily allotment & exemptions
Sidebar → Config. Set how many substitutes the district can deploy on each weekday; add an exemption (a date or range) to suspend the normal allotment over a scheduled break. Every day cell computes:
Subs Available = Daily Allotment for that weekday − Total Subs Needed that day
Save / Load .pdcal files
These are backup and restore tools now, not primary storage — the fiscal year already lives in the database. Save downloads the selected fiscal year as JSON. Load reads a file back in and overwrites that fiscal year's live data for every viewer. Take a fresh Save before you Load if you're not certain.
Danger zone — Delete All Events
Wipes every event in the selected fiscal year. Doesn't touch other fiscal years, tags, or substitute settings — but there's no undo. Save a backup first.
Importing & exporting data
Sidebar → Import CSV / Export CSV / CSV Template — all Super Administrator only. Import expects a raw Kickup opportunity export (the "Scratch Sheet" CSV, straight from kickup.co), not a hand-built file. You'll see a create/update/ignore/error preview before anything is written, then a full report afterward.
Day-to-day operation
Sign in with Google from the top-right button.
Pick a fiscal year from the dropdown in the left controls sidebar. Each year's data is independent.
Start from Overview — a tile per month with a subs available/needed/remaining summary. Click a tile to open that month.
Add an event with the + button on any day. Required fields mirror Kickup: Event ID, Event Name, Learning Type, Start/End Date, and Hours, using
MM/DD/YYYY HH:MM:SS AM/PM.Set tags and an event color — pick one selected tag as the Event Color Tag; the card takes its color.
Reschedule by drag, or copy/cut/paste an event card between days.
Editing something outside your scope
A card outside your assigned scope opens read-only with a "View only" note — the server enforces the same rule, so this isn't just a UI hint.
The light/dark toggle at the bottom of the sidebar is a local display preference — it works for every role and isn't part of the shared plan data.
Quick reference
Environment variables
wrangler.jsonc. Not secret. Must match the client ID in index.html.Role capability matrix
| Capability | Default | Editor | Admin | Super Admin |
|---|---|---|---|---|
| View calendar | ✓ | ✓ | ✓ | ✓ |
| Create/edit/delete events (in scope) | — | ✓ | ✓ | ✓ |
| Manage Users (up to own rank) | — | — | ✓ | ✓ |
| Allotments, tags, CSV, Save/Load, Delete All | — | — | — | ✓ |
| Manage Super Administrator accounts | — | — | — | ✓ |