{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://jeremarc.com/projects/calendar-lab/schema.json",
  "title": "Calendar Lab plan",
  "description": "A capacity plan over a range of ISO calendar weeks: events that consume people,\ntime off that removes them, and milestones that mark a day. Weekends, public holidays\nand company days carry no capacity, so nothing has to be planned around them by hand.\n\nTo hand a plan over, either paste this JSON into the Export tab of\nhttps://jeremarc.com/projects/calendar-lab, or append it to that URL as\n\"#p=\" + base64url(JSON.stringify(plan)) — the whole plan travels in the link,\nand opening it loads the plan. Nothing is uploaded anywhere.\n\nThe importer repairs rather than rejects: unknown keys are ignored, missing ids\nare generated, a name that matches no resource or category creates one, and an\nevent with no resolvable date starts today. What it understood is reported back\nin the app when the plan loads.",
  "type": "object",
  "additionalProperties": true,
  "properties": {
    "title": {
      "type": "string",
      "maxLength": 90,
      "description": "Plan name. Alias: name."
    },
    "year": {
      "type": "integer",
      "minimum": 1970,
      "maximum": 2999,
      "description": "Year the weeks belong to."
    },
    "weekFrom": {
      "type": "integer",
      "minimum": 1,
      "maximum": 53,
      "description": "First calendar week shown. Aliases: cwFrom, fromWeek, from."
    },
    "weekTo": {
      "type": "integer",
      "minimum": 1,
      "maximum": 53,
      "description": "Last calendar week shown; wraps into the next year when lower than weekFrom. Aliases: cwTo, toWeek, to. At most 40 weeks are drawn."
    },
    "workdays": {
      "type": "array",
      "items": {
        "description": "Mon…Sun, or 1 (Monday) … 7 (Sunday). Case-insensitive.",
        "anyOf": [
          {
            "type": "string",
            "examples": [
              "Mon",
              "tuesday",
              "Fri"
            ]
          },
          {
            "type": "integer",
            "minimum": 1,
            "maximum": 7
          }
        ]
      },
      "description": "Which weekdays carry capacity. Defaults to Mon–Fri. Days left out are shown but cost nothing."
    },
    "showOffDays": {
      "type": "boolean",
      "description": "Show the non-working days as empty columns (default true). Alias: showWeekends."
    },
    "resources": {
      "type": "array",
      "maxItems": 40,
      "description": "The people or machines being planned. A bare string is a name at 40 h/week.",
      "items": {
        "anyOf": [
          {
            "type": "string"
          },
          {
            "type": "object",
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string",
                "maxLength": 60
              },
              "hoursPerWeek": {
                "type": "number",
                "minimum": 0,
                "maximum": 168,
                "description": "Capacity when fully available. Alias: hours. Default 40."
              },
              "region": {
                "type": "string",
                "description": "Where this person sits, for public holidays: a country (\"FR\") or an ISO subdivision (\"DE-BY\"). Defaults to the plan's region. Aliases: state, subdivision, location.",
                "examples": [
                  "DE-BY",
                  "DE-BE",
                  "FR"
                ]
              }
            },
            "required": [
              "name"
            ]
          }
        ]
      }
    },
    "categories": {
      "type": "array",
      "maxItems": 24,
      "description": "Colour groups. A bare string is a name; the colour is then chosen in order.",
      "items": {
        "anyOf": [
          {
            "type": "string"
          },
          {
            "type": "object",
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string",
                "maxLength": 60
              },
              "slot": {
                "type": "integer",
                "minimum": 0,
                "maximum": 7,
                "description": "Palette index."
              },
              "colour": {
                "type": "string",
                "enum": [
                  "Blue",
                  "Orange",
                  "Aqua",
                  "Yellow",
                  "Magenta",
                  "Green",
                  "Violet",
                  "Red"
                ],
                "description": "Named palette colour. Alias: color."
              }
            },
            "required": [
              "name"
            ]
          }
        ]
      }
    },
    "holidays": {
      "description": "Public holidays, which behave like a weekend: no capacity, no demand, no clash. A region string on its own — \"DE-BY\", or \"FR\" for a country — is enough; the days are fetched from date.nager.at when the plan opens and then stored with it. Days a person keeps come from their own region, else from this one. A day the whole team keeps closes the column; a day only some of them keep costs only those people.",
      "anyOf": [
        {
          "type": "string",
          "examples": [
            "DE-BY",
            "DE",
            "FR"
          ]
        },
        {
          "type": "object",
          "properties": {
            "country": {
              "type": "string",
              "description": "ISO country code. Alias: countryCode."
            },
            "subdivision": {
              "type": "string",
              "description": "Default region for people without their own. Aliases: region, state.",
              "examples": [
                "DE-BY"
              ]
            },
            "days": {
              "type": "array",
              "description": "Resolved days, as the app stores them. Leave it out to have them fetched.",
              "items": {
                "type": "object",
                "properties": {
                  "date": {
                    "type": "string",
                    "examples": [
                      "2026-06-04"
                    ]
                  },
                  "name": {
                    "type": "string",
                    "examples": [
                      "Fronleichnam"
                    ]
                  },
                  "regions": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Who keeps it: a bare country code for a national day, subdivisions otherwise.",
                    "examples": [
                      [
                        "DE"
                      ],
                      [
                        "DE-BW",
                        "DE-BY"
                      ]
                    ]
                  }
                },
                "required": [
                  "date",
                  "name"
                ]
              }
            }
          }
        }
      ]
    },
    "companyDays": {
      "type": "array",
      "maxItems": 120,
      "description": "Days the company itself takes, in whole or in part — the shutdown between Christmas and New Year, or the half day on New Year's Eve that no public holiday list knows about. They belong to the calendar, not to a person, so everybody is covered at once. Aliases: companyHolidays, closures, officeDays.",
      "items": {
        "type": "object",
        "additionalProperties": true,
        "properties": {
          "date": {
            "description": "An ISO date (\"2026-10-05\"), or a calendar week with an optional weekday (\"CW 41\", \"cw41 Thu\", \"kw 41 fri\"). Weeks are ISO weeks. Aliases: on, start.",
            "type": "string",
            "examples": [
              "2026-10-05",
              "CW 41 Thu"
            ]
          },
          "name": {
            "type": "string",
            "maxLength": 90,
            "description": "Aliases: title, label."
          },
          "fraction": {
            "description": "How much of the day is off. 1 (or nothing given) closes it; 0.5, 50, \"50%\" or \"half\" take half of it. Aliases: off, percent, share, amount — or \"half\": true.",
            "anyOf": [
              {
                "type": "number",
                "minimum": 0,
                "maximum": 100
              },
              {
                "type": "string",
                "examples": [
                  "half",
                  "50%",
                  "quarter"
                ]
              }
            ]
          },
          "who": {
            "description": "Limit it to some people, by name or id. Left out, it applies to everyone — which is what a company day usually is. Aliases: resourceIds, resources, people.",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            ]
          },
          "everyYear": {
            "type": "boolean",
            "description": "Repeat on the same date every year, for a plan that crosses New Year. Aliases: annual, yearly, repeat."
          }
        },
        "required": [
          "date",
          "name"
        ]
      }
    },
    "events": {
      "type": "array",
      "maxItems": 400,
      "items": {
        "type": "object",
        "additionalProperties": true,
        "properties": {
          "title": {
            "type": "string",
            "maxLength": 120,
            "description": "Aliases: name, label."
          },
          "kind": {
            "type": "string",
            "description": "work (consumes capacity), timeoff (removes it), or milestone (a marker on one day). Alias: type. Also understood: task/project → work; leave, holiday, vacation, pto, sick, off → timeoff; deadline, marker → milestone.",
            "examples": [
              "work",
              "timeoff",
              "milestone",
              "vacation",
              "deadline"
            ]
          },
          "start": {
            "description": "An ISO date (\"2026-10-05\"), or a calendar week with an optional weekday (\"CW 41\", \"cw41 Thu\", \"kw 41 fri\"). Weeks are ISO weeks. Aliases: date, on.",
            "type": "string",
            "examples": [
              "2026-10-05",
              "CW 41 Thu"
            ]
          },
          "end": {
            "description": "An ISO date (\"2026-10-05\"), or a calendar week with an optional weekday (\"CW 41\", \"cw41 Thu\", \"kw 41 fri\"). Weeks are ISO weeks. Alias: until. Ignored for milestones.",
            "type": "string",
            "examples": [
              "2026-10-05",
              "CW 41 Thu"
            ]
          },
          "cw": {
            "type": "integer",
            "minimum": 1,
            "maximum": 53,
            "description": "Calendar week, instead of dates. With from/to it picks the days inside that week. Aliases: week, weekFrom, cwFrom."
          },
          "toCw": {
            "type": "integer",
            "minimum": 1,
            "maximum": 53,
            "description": "Last calendar week for an event spanning weeks. Aliases: cwTo, weekTo."
          },
          "from": {
            "description": "First weekday within cw. Default Monday.",
            "anyOf": [
              {
                "type": "string",
                "examples": [
                  "Mon",
                  "tuesday",
                  "Fri"
                ]
              },
              {
                "type": "integer",
                "minimum": 1,
                "maximum": 7
              }
            ]
          },
          "to": {
            "description": "Last weekday within the last week. Default Friday.",
            "anyOf": [
              {
                "type": "string",
                "examples": [
                  "Mon",
                  "tuesday",
                  "Fri"
                ]
              },
              {
                "type": "integer",
                "minimum": 1,
                "maximum": 7
              }
            ]
          },
          "days": {
            "type": "integer",
            "minimum": 1,
            "description": "Length in calendar days from the start, instead of an end. Alias: length."
          },
          "who": {
            "description": "The people it needs, as names or ids; a single string is fine. A name that matches nothing creates a resource at 40 h/week. Aliases: resourceIds, resources, people, assignees.",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            ]
          },
          "category": {
            "type": "string",
            "description": "Colour group, as a name or id; a new name creates one. Aliases: categoryId, colour, color, stream."
          },
          "load": {
            "type": "number",
            "minimum": 0,
            "maximum": 400,
            "description": "Percent of each assigned person per working day. Default 100 for work, 0 for a milestone. For time off it is how much of the day is removed. Aliases: percent, allocation."
          },
          "alsoWorking": {
            "type": "array",
            "items": {
              "description": "An ISO date (\"2026-10-05\"), or a calendar week with an optional weekday (\"CW 41\", \"cw41 Thu\", \"kw 41 fri\"). Weeks are ISO weeks.",
              "type": "string",
              "examples": [
                "2026-10-05",
                "CW 41 Thu"
              ]
            },
            "description": "Non-working days this event works through anyway — a weekend release, or a public holiday someone gives up. Each adds that person's day of capacity and spends it. Aliases: extraDays, weekendDays."
          },
          "notes": {
            "type": "string",
            "maxLength": 600,
            "description": "Aliases: note, description."
          }
        },
        "required": [
          "title"
        ]
      }
    }
  },
  "examples": [
    {
      "title": "Q4 rollout",
      "year": 2026,
      "cwFrom": 41,
      "cwTo": 45,
      "holidays": "DE-BY",
      "companyDays": [
        {
          "date": "2026-12-24",
          "name": "Christmas Eve",
          "half": true,
          "everyYear": true
        },
        {
          "date": "2026-12-31",
          "name": "New Year's Eve",
          "off": "50%",
          "everyYear": true
        }
      ],
      "resources": [
        "Ada",
        {
          "name": "Bruno",
          "hours": 32,
          "region": "DE-BE"
        }
      ],
      "categories": [
        {
          "name": "Delivery",
          "colour": "Blue"
        },
        "Research"
      ],
      "events": [
        {
          "title": "Kickoff",
          "type": "milestone",
          "cw": 41,
          "from": "Mon",
          "category": "Delivery"
        },
        {
          "title": "Discovery",
          "cw": 41,
          "from": "Tue",
          "to": "Fri",
          "who": [
            "Ada"
          ],
          "category": "Research"
        },
        {
          "title": "Build",
          "cw": 42,
          "toCw": 43,
          "who": [
            "Ada",
            "Bruno"
          ],
          "load": 80,
          "category": "Delivery"
        },
        {
          "title": "Bruno on leave",
          "type": "vacation",
          "cw": 44,
          "from": "Mon",
          "to": "Wed",
          "who": "Bruno"
        },
        {
          "title": "Release window",
          "cw": 44,
          "from": "Sat",
          "to": "Sun",
          "who": "Ada",
          "alsoWorking": [
            "cw 44 Sat",
            "cw 44 Sun"
          ],
          "category": "Delivery"
        },
        {
          "title": "Go live",
          "kind": "deadline",
          "start": "CW 45 Thu",
          "category": "Delivery"
        }
      ]
    }
  ]
}