The sheets¶
Every value the solver uses comes from a Google Sheet. Names become Skedge identifiers by
this rule: trim, lowercase, replace each run of characters that are not letters or digits
with one underscore, and drop underscores at the ends. Archery 1 & 2 becomes
activities.clinics.archery_1_2; Cam VL becomes staff.cam_vl. puppet-strings names prints
the identifier for every sheet value.
Clinic_Data¶
The loader reads the combined tab (the one with a Category column). The per-category
tabs are for people.
| Column | Meaning |
|---|---|
Clinic_Name |
The activity's name. A name ending in (DBL) spans two adjacent clinic blocks. |
Slots |
Camper slots. Shown, not used by the solver. |
Staff_Required |
Number of positions (1st, 2nd, 3rd). Staff_Requested is accepted too. |
RAL_Required |
One digit per position, in order: 53 means the 1st needs RAL 5 and the 2nd RAL 3. The digit count must equal Staff_Required. |
LG_Required |
Optional. Lifeguards in addition to Staff_Required. A water clinic with one facilitator and one lifeguard has Staff_Required 1 and LG_Required 1. Every lifeguard position needs the LIFEGUARD skill at RAL 5. |
Category |
Becomes activity.<category>, for example activities.clinics.ropes. |
Built in: activities.clinics is every clinic. Positions are roles.first, roles.second,
roles.third for the facilitators, then roles.lifeguard, roles.lifeguard_2 for the
lifeguards.
Skills¶
Main tab. Three header rows, then one row per staff member. Row 2 holds the skill name
and row 3 the rank; together they form the skill's full name (Canopy Tour 1st). A column
whose row 2 is blank is a date column and is skipped. Column A is the name, column B the
RAL (its first number is read, so 4 (6/12) is 4).
Each cell says where that person stands on that skill:
| Cell | Meaning | Can fill the position | roles.trainee becomes |
|---|---|---|---|
✓, WCF |
checked off | yes | scaffolded |
Trainer |
checked off and may supervise a scaffold | yes | scaffolded |
w/ scaf, w/scaf, Brief scaf |
needs a scaffold | no | scaffolded |
w/ shadow |
needs to shadow | no | shadow |
blank, Past Ex, Interested, . |
not checked off | no | shadow |
| anything else | unknown; treated as not checked off and listed as a warning | no | shadow |
This table lives in one place in the code, STATUS_WORDS at the top of
puppet_strings/sheets/skills.py. To add a status, add a row there.
Positions tab. Clinic_Name | 1st | 2nd | 3rd: the skill each position requires.
Any means no checkoff is needed. Every clinic on Clinic_Data must have a row here with a
cell filled in for each of its positions, and every skill named must be a column heading on
the main tab (heading plus rank, e.g. Canopy Tour 1st). Clinic and skill names are matched
the way every other name is, ignoring case, spacing and punctuation, so a Candle making
column and a Candle Making cell are the same skill. A missing row, a blank cell, or a name
nothing matches stops loading with an error listing every one. None of them is read as
"anyone may facilitate" — that is what Any is for.
The staff roster is the set of rows on the main tab, and staff names all of them.
staff.clinic_trainers is everyone with at least one Trainer cell. A skill itself is not
a name: it is asked for by the position that needs it, on a clinic or on a cabin act.
Staff Categories (one per span, in the schedules tree)¶
One column per category, the name in row 1 and members below. Each becomes
staff.<category> (staff.counselor, staff.director, staff.village_hero). Members must
be on the Skills sheet. A column headed etc. is ignored.
Every span keeps its own, in its folder in the schedules tree,
because who is on staff changes from session to session — so staff.counselor follows the
date being scheduled. A span with no Staff Categories spreadsheet is a load error naming
the folder it looked in: loading with no categories at all would quietly drop every request
that names one.
This sheet is also who is at camp. staff is everyone it names, not everyone with a
Skills row: the Skills sheet keeps every person who has ever worked here, including staff
who have left and staff who come for one session. Somebody with a Skills row and no category
is away that span, which is the same to the solver as resting all day — no category offers
them and no position can be filled by them. Their name still resolves, so a request that
names them is simply not met rather than an error, and the load says how many are away.
Offerings (one per day, in the schedules tree)¶
The grid you already fill in, a tab of the day's own spreadsheet. The Clinic Schedule
spreadsheet chosen in Configure is the template a new day is started from. Row 1 is the weekday; row 2 has Clinic 1 … Clinic 4
above each group of columns. Below, category headings in capitals and clinic names. The
lookup columns (slots, staff) are ignored, as is everything below a Cancelled row.
Each row 2 heading must name a block on the Blocks sheet — Clinic 1 finds the block
clinic_1, Playstation the block playstation. A heading that names no block takes its
whole column with it: nothing under it is offered, and the block looks free all day. That is
a warning, listed with the other load warnings, not an error.
A (DBL) clinic must appear in two adjacent clinic blocks; it becomes one instance with
the same staff in both. The tab has no date: the target date is the --date argument or
the app's date picker. A weekday mismatch is a warning.
Cabin act sheets (the Cabin Acts folder)¶
The cabin act block is a time campers may do anything in, and somebody other than the Puppet Master fills in what each cabin is doing and who they want along. There is one spreadsheet per session and week, all of them in one Drive folder chosen in the Configure pane, and every one of them is read each time the sheets are read.
A cabin act is an activity, like a clinic: a thing that happens, with a position per person it needs. It is not a request, so nothing has to be imported and there is no button to press. Two requests ask for all of them:
REQUEST EACH activities.cabin_acts.at_cabin_act DURING blocks.cabin_act
REQUEST EACH activities.cabin_acts.at_rest_hour DURING blocks.rest_hour
The Blocks tab must therefore have a cabin_act row and a rest_hour row.
The sheet's title says which week it is for: S5W1 is session 5, week 1, matched
anywhere in the title, so Cabin Act Sorting - S5W1 works. Those two numbers and the
weekday give the date from the Calendar sheet, so nothing on the grid has to carry one.
Only the Board tab is read; the Support Requests tab says the same thing a second time and is ignored. Its layout:
| Where | What |
|---|---|
| Row 1 | The sheet's title |
| Row 2 | A weekday merged over its four columns, then the spare Extra columns |
| Column A | The cabin, merged down its block of rows: M1, P4, O2 |
| Inside a cabin's block | A label column and a value column beside it, per weekday |
The labels read are Activity and HEROES; everything else on the grid is for the people
filling it in. Rows are found by their labels rather than by counting, so adding a row to
the cabin block changes nothing. Only Monday to Friday are scheduled: the Extra columns
are not days and are skipped.
HEROES is a comma-separated list, and each item becomes one position of the activity, so asking for two people asks for two people. An item is either:
| Item | The position it makes |
|---|---|
| A staff member on the Skills sheet | Only that person may hold it |
| A Staff Categories column | Anyone in that category may hold it |
| A Skills column | Anyone checked off on that skill may hold it |
An item that is none of the three is a warning naming the cabin and the day, and the rest of that cabin act is still staffed. A cabin act with an empty HEROES cell asks nothing of anybody and becomes no activity at all.
An Activity that starts or ends with RH or Rest Hour (RH: Fruit Ninja,
REST HOUR Aerial Yoga, Stranded in the back 40 - Rest Hour) is moved to rest hour, and
the cabin rests in the cabin act block instead. One that mentions rest hour only in the
middle, such as CA: Blackberry picking, RH: muffins, is a warning and is treated as an
ordinary cabin act. Ask for its rest hour part in a request of its own.
The names it makes are the board of the day being scheduled: activities.cabin_acts for
its acts, the two halves of it, activities.cabin_acts.at_cabin_act and
activities.cabin_acts.at_rest_hour, and each act under its half by its cabin, for
example activities.cabin_acts.at_cabin_act.p4. A cabin has a name only on a day it has
an act, and which half it is under can change from one day to the next.
Blocks (config spreadsheet)¶
One row per time block. Blocks are the units the solver assigns staff to.
| Column | Meaning |
|---|---|
block_id |
The block's name, used in requests as blocks.<block_id>. |
start, end |
The block's times, written any ordinary way: 8:30, 08:30 and 8:30 AM all mean the same thing. Blocks may overlap; the solver never gives one person two assignments that overlap in time. |
day_types |
Comma-separated, from first_day, last_day, weekday, weekend. Which kinds of day the block exists on. |
program_type |
Comma-separated, from main season, other. Which programmes it exists in. |
categories |
Comma-separated. Groups of blocks a request can name at once: blocks.all_clinics, blocks.meals. blocks (every block) is built in and may not be used as a category name. |
A block exists on a day when the day runs one of its programmes and is one of its kinds
of day. Nothing writes a day's kinds down: a day is weekday or weekend by the calendar,
and also first_day or last_day when it is one end of its Calendar row, so most days are
two kinds at once.
One block id is spoken for: cabin_act is the slot the
cabin act sheets are scheduled into.
Example:
| block_id | start | end | day_types | program_type | categories |
|---|---|---|---|---|---|
| breakfast | 08:00 | 09:00 | weekday, weekend | main season, other | meals |
| clinic_1 | 09:15 | 10:30 | weekday | main season | all_clinics |
| cabin_act | 13:00 | 14:00 | weekday | main season | |
| pack_out | 09:15 | 11:00 | last_day | main season | |
| playstation | 17:00 | 18:00 | weekday, weekend | main season, other |
So the clinic blocks run Monday to Friday of a main season session; breakfast, lunch and
playstation run every day of every programme; and pack-out runs only on the day a session
ends. If every day has the same shape, write weekday, weekend on every block.
Blocks are the real periods of the day, not 30-minute slices. A short task such as a
break is written with FOR EXACTLY 30m and takes part of a block; the Staff View shows the rest
of that block as DYOW/WPs ("do your own work or work projects"). See the
Skedge reference.
Staff with no assignment in the playstation block are marked Available in the Staff
View.
Calendar (config spreadsheet)¶
One row per span of days: a session, or anything else camp runs. A fortnight is one row, not fourteen.
| Column | Meaning |
|---|---|
name |
What the span is called. A main season row is reached by its number rather than this name, but the name still has to be there and has to be unique. |
start date, end date |
Both included. Two rows may not cover the same day. Format the column as a date and write it however you like — see below. |
program type |
main season or other, matched against each block's program_type. |
Example:
| name | start date | end date | program type |
|---|---|---|---|
| Session 1 | 2026-06-14 | 2026-06-27 | main season |
| Session 2 | 2026-06-28 | 2026-07-11 | main season |
| Family Camp | 2026-09-01 | 2026-09-05 | other |
The main season rows are numbered in sheet order, and that number is the name in
Skedge: the first is dates.session_1, the second dates.session_2. Anything
else is reached by its name, as dates.family_camp. Inserting a main season row
renumbers the ones after it, so a request naming dates.session_4 follows the sheet.
A row that is not main season cannot be named so that it would read as one of the other
date names — Season, Target, Session 4, Session Target — and a load says so.
The target date must be one of these spans. It is the first thing checked on every load, before any other sheet is read, because no block exists on a day camp is not running and so nothing else would mean anything.
Dates may be written however the sheet shows them. Google Sheets hands the program a
date cell as whatever it displays, so a column formatted as a date arrives as
6/14/2026, 14 June 2026 or Sunday, June 14, 2026 depending on the sheet's locale and
format. All of those are read, as is plain 2026-06-14 typed as text, and an unformatted
cell's serial number. The one thing that cannot be worked out is a numeric date whose first
two parts are both twelve or less: 6/7/2026 is the 7th of June on a sheet set to the
United States and the 6th of July on most others. That is what date_order in
config.toml is for — mdy by default, dmy for the rest of the world — and it is only
consulted for dates that could be read both ways.
Weeks are not written down. A span's week 1 is its first seven days, week 2 the next
seven, and so on, with a short week at the end if it does not divide evenly. A row that
starts on the day your weeks start therefore lines up with the calendar, and the request
manager's calendar shows each row's S<session> and W<week> down the left-hand side, so
you can see at a glance whether it does.
dates.session_4.week_2 is the second week of session 4,
{dates.session_4 & dates.mondays} is every Monday of it, and
dates.season is every date the sheet covers. See
Dates for the full list of date names.
Requests (on this computer)¶
Requests are not a sheet. Nobody edits them anywhere but in the request manager, and only
one Puppet Master schedules at a time, so they live in one file on the Puppet Master's
computer, ~/.config/puppet_strings/requests.sqlite. Saving a request is instant and
costs no Google requests.
Backups on Drive. The requests are the one thing Puppet Strings keeps that no sheet
holds, so a copy of them goes where the sheets are: a Database Backups folder in the
Puppet Strings folder, one file per snapshot, named for the moment it was taken
(requests-2026-09-24-143207.sqlite). The window takes one on each load, and skips it when
no request has changed since the last one went, so a day that writes nothing sends nothing.
Configure → Requests → Back up takes one whenever you ask, and
puppet-strings backup-requests does the same from the command line.
Nothing older is ever deleted or overwritten: several snapshots is the point, since the newest is no use if what went wrong was a request rewritten three days ago. To put one back, download it from Drive and Import… it, which is the handover path below.
Handing over. When another Puppet Master takes over, Configure → Requests → Export…
writes the requests to a file, and they Import… it on their computer. An import
replaces every request there with the file's; the ones it replaced are kept beside the
file as requests.before-import.sqlite until the next import. A file that is not a
requests file, or holds a request the app would refuse, is refused whole and changes
nothing. From the command line:
puppet-strings export-requests handover.sqlite
puppet-strings import-requests handover.sqlite
Every request has a scope: the days it is read on. A load of a date reads every request whose scope covers that date, and no other, so a request does its work over exactly the days of its scope and the rest of the season never sees it.
| Scope | Read on | For |
|---|---|---|
| Day | That one date | Anything asked of one day. |
| Week | Every date of its week of the session | A week's arrangements. |
| Session | Every date of its session | What was asked of one session in particular. A new request is scoped to its session unless you say otherwise. |
| Season | Every date of the year's season | What holds all season: the legal limits, the standing agreements. |
Weeks and sessions are the Calendar sheet's: a span that is not a numbered session is its
own session, and its weeks are its days seven at a time. The scope box in the request
manager offers the day, week, session and season of the date being scheduled. A request's
Skedge can still narrow the days it is about, with ON: its scope says when it is read,
and its Skedge what it asks for once it is.
A day's clinics are asked for by one season-long request, REQUEST EACH activities.clinics.offerings, which
reads whichever day's Offerings tab is loaded; see the Skedge
reference.
Each request has these fields, all edited in the request manager:
| Field | Meaning |
|---|---|
id |
Unique and stable. The app gives a new request the next free number for its scope — season-2, s4-1 for session 4, s4w2-1 for its second week, jun08-1 for a day — and never changes it. Appears in the solver's report. |
description |
Plain language, for people. May be left empty: the id is what names the request. |
skedge |
The request itself; see the Skedge reference. |
priority |
One of MUST_HAPPEN, CLINIC, HIGH, MEDIUM, LOW. STABILITY is the solver's own during a same-day change and is never a request's. |
weight |
A positive number, 1 unless said otherwise. Always 1 with MUST_HAPPEN. |
tags |
Any labels you like, for filtering in the request manager. |
group |
The one group the request is on in the request manager, such as Special daily requests, or none. |
requester |
Who asked for this, as a staff name: mary_kate. None if it is nobody's in particular. A name that is not on the Skills sheet makes the request invalid, so a typo is caught rather than lost. |
created |
The date it was made, for the record. |
Adjustments (config spreadsheet)¶
One row per staff member per date, for the day only: who is resting, and whose RAL is down. See Same-day changes for what each column does and why absence lives here rather than in a request. The tab is optional; without it nobody is adjusted.
| date | staff | resting | RAL_penalty | note |
|---|---|---|---|---|
| 2026-06-15 | Alesa | all day | sick | |
| 2026-06-15 | Vic | 1 | short sleep |
Mappings (config spreadsheet)¶
A mapping is a table that takes one or more names and gives back a value. There are two kinds:
- A numeric mapping gives a number, such as how much each staff member enjoys each
clinic. A request scores assignments with it using
MAXIMIZEorMINIMIZE:PREFER EACH s IN staff DO EACH c IN activities.clinics MAXIMIZE mappings.preference(s, c). - Any other mapping gives a name, such as each counselor's buddy HERO, who covers their
cabin at dinner. A request can put the call anywhere a name goes:
REQUEST mappings.buddy(c) DO 'cabin cover' DURING blocks.evening.
Mappings take two kinds of tab in the config spreadsheet:
- One tab named
Mappingsthat lists every mapping you have, what it takes and what it gives. Think of it as a table of contents. It has one row per mapping. - One tab per mapping holding the rows themselves, named
mapping_followed by the mapping's name:mapping_enjoyment.
If you have no mappings yet, create the Mappings tab with just its header row and leave
it empty.
Step by step: an enjoyment mapping¶
1. Add a row to the Mappings tab.
| mapping | keys | value | scale_min | scale_max | default |
|---|---|---|---|---|---|
| enjoyment | staff, activities.clinics | numeric | 1 | 5 | 3 |
| Column | What to put there |
|---|---|
mapping |
A short name. It becomes mappings.enjoyment in requests, and names its tab mapping_enjoyment. |
keys |
Comma-separated. What each key may be, as Skedge sets: one per argument the mapping takes. staff, activities.clinics means one rating per staff member per clinic. A bare namespace such as staff means any name in it; a set expression such as {staff - staff.counselor} narrows it down. |
value |
numeric for a number, or a Skedge set the value must come from. |
scale_min, scale_max |
Numeric mappings only. The lowest and highest rating you will ever enter. Ratings are converted to 0–1 against this scale, not against whatever ratings happen to exist, so adding a new rating never changes how the old ones weigh. Leave both blank for any other mapping. |
default |
Optional. What a key with no row of its own gives. For a numeric mapping it is a number. Leave it blank and an unrated pair is worth scale_min, the bottom of the scale. Set it to the middle of the scale (3 of 1–5 above) and an unrated pair counts as ordinary rather than disliked. A default outside the scale is a load error. |
2. Create a tab named mapping_enjoyment. Give it one column per key, headed key1,
key2 and so on in the order the keys cell lists them, and then a value column:
| key1 | key2 | value |
|---|---|---|
| Dylan | Archery 1 & 2 | 5 |
| Dylan | Candle Making | 3 |
| Mogee | Candle Making | 4 |
Write the names as they appear on the other sheets (Dylan, Archery 1 & 2), not as
Skedge identifiers, so you can paste rows from elsewhere. A value outside the scale is a
load error.
3. There is no step 3. A staff-and-clinic pair with no row of its own is worth the
default, so you only need rows for the ratings you actually have. A second mapping, say
variety_need keyed by staff, is another row on the Mappings tab and another tab named
mapping_variety_need with columns key1 and value.
The default matters more than it looks. With a blank default, every clinic nobody has rated sits at the bottom of the scale, so the solver treats "not rated yet" as "disliked" and crowds people onto the few clinics that are rated. A default in the middle of the scale says "no opinion", and only the ratings you actually enter pull for or against.
Step by step: buddy HEROs¶
A cabin has the same buddy for the whole session, so the buddies are a mapping from each counselor to someone who isn't one.
1. Add a row to the Mappings tab.
| mapping | keys | value | scale_min | scale_max | default |
|---|---|---|---|---|---|
| buddy | staff.counselor | {staff - staff.counselor} | ANY 1 {staff - staff.counselor - staff.director} |
For a mapping that gives a name, the default is a Skedge phrase: what a call stands for
when its key has no row. ANY 1 {…} lets the solver pick anyone from that set. A
single name such as staff.alan also works, and so does ALL {…}. A default of more
than one name needs its quantifier. Leave the default blank and every counselor needs a
row: a request that asks about one without a row is an error.
2. Create a tab named mapping_buddy.
| key1 | value |
|---|---|
| Dylan | Alan |
| James | Sarah |
Every row is checked when the day loads, the same way a request is. A key that isn't a
counselor, a buddy who is a counselor, a name that doesn't exist and a default that reaches
outside the value set are all load errors, and each one names the row. The one exception is
a person who is resting all day or away. They're in no category that day, so whether they
are a counselor can't be checked, and it isn't. A buddy who is off that day is passed over
for the default, so their cabin is still covered.
The schedules tree¶
Everything Puppet Strings reads lives in one folder, chosen in the Configure pane. Nothing else is configured: the folder is walked and things are recognised by their names.
Puppet Strings/
Database Backups a copy of the requests, one file per snapshot
2027/
Clinic_Data the season's reference sheets, found by name
Clinic_Schedule
Skills
Config
Requests a tab per session; see above
Main Season/
Session 1/
Staff Categories one spreadsheet, for that span's staff
Monday_1 one spreadsheet per day
Tuesday_1
Monday_2 the second week's Monday
Other/
August Family Camp/
Staff Categories
Monday_1
The root holds a folder per year and the year is taken from the date being scheduled. A
reference sheet is looked for in that year's folder first and at the root second, so a
season can keep its own Skills without copying everything else, and sheets that never
change can sit at the root.
Under the year, the programme is the span's program type and the folder under it is its
name, both read off the Calendar, so nothing is written
down twice. A day is named for its weekday and which week of its span it falls in, because a
fortnight reaches Monday more than once.
Reload makes a day's spreadsheet when it is not there yet, with the Offerings grid copied from the Clinic Schedule template — weekday set to its own — and the other tabs empty, ready for Publish. It never touches a sheet that is already there, so a grid you have pruned stays pruned.
A day's spreadsheet¶
| Tab | What it is |
|---|---|
Offerings |
The grid of what runs that day, started from the template and edited by hand |
Assignments |
One row per assignment: the record the solver reads back |
Staff View, Clinic View, Report |
Written on Publish, for people to read |
Changes |
Added by a same-day re-solve, saying what moved |
The Assignments tab is the one the program reads; the views are drawn from it and cannot
be read back, because a grid of names does not say who was on what for how long. A day whose
Assignments tab is empty has been set up but not solved, which is how the app knows
whether a date is published.
| Column | Meaning |
|---|---|
staff, activity, role, block |
Who does what, in which role, in which block |
start |
Where the task starts inside its block, written as HH:MM |
minutes |
How long it lasts; a clinic fills its block |
source |
offering, or the request id that required it |
Ad hoc tasks are written in quotes ('counselor hour').
These tabs are overwritten on every publish:
- Staff View: one row per staff member at camp, one column per block, with each block's
tasks in time order and
DYOW/WPsfor unused time. Only the people the span's Staff Categories sheet names get a row; the Skills sheet keeps everyone who has ever worked here, and the ones it lists who are not here this session are left off both views. Somebody anEXCLUDEhas taken out of a block has that request's label in the cell —offsite,at the dentist— rather than an empty one. A merged title says which day it is, the names and the block headings are frozen so they stay put as the grid is scrolled, each block column carries the colour it has on the Clinic View, every other row is banded, and the columns are wide enough — and wrapped — for two tasks in one block to read as two lines. -
Clinic View: the printable clinic schedule. A merged title (
Day 4, Session 1 - Wednesday), a header of clinic blocks, then one row per clinic grouped by category in Clinic_Data order with a blank row between groups. A clinic with two positions takes two rows (1st above 2nd); trainees get aShadoworScaffoldrow beneath. Nothing has a row unless somebody is on it: a clinic that was offered and could not be staffed, or that a request put off to another day, is not happening today and is not on the schedule. The Report is what names a clinic that was asked for and did not run. After the clinics come the other tasks (counselor hours, breaks) one name per row, a row perEXCLUDElabel with the people it takes out of the day, and a finalDYOW/WPsgroup listing everyone with nothing in that block. Somebody who is away is in their own row rather than among the people who are free. Title and clinic names are bold and the top rows are frozen.It is also coloured, so a printed copy can be read across a room. Each clinic block has a colour of its own, on its heading and on every cell of that column with a name in it, which leaves the gaps white: a clinic that runs in one block and not another shows as blank in the block it does not run in. Each category on Clinic_Data has a colour of its own too, on the name of every clinic in it, so a category reads as one run down the left-hand column. Both sets of colours are the lists in
puppet_strings/publish/palette.py; change a colour there and the next publish uses it. Colours are given out in sheet order and wrap round if a day ever has more clinic blocks, or Clinic_Data more categories, than the list has colours. - Report: unsatisfied and deferred requests, conflicts, and solver notes. One row per request, not perEACHcopy: therequestcolumn is the request's own id, and where only some copies of a request went wrong, they are listed after the description —no break at lunch (2026-09-18). - Changes: what a same-day re-solve moved, written only when the day was already published. See Same-day changes.