{
  "name": "One private lesson package, sorted into the seven states a lesson can be in",
  "page": "https://otherworldlabs.com/private-lesson-practice-log/",
  "endpoint": "https://otherworldlabs.com/private-lesson-practice-log/api.json",
  "question": "For one private student's lesson package, how many lessons are still to come, how many make-ups are still owed, and which of the teacher's own rules closed each of the rest, given the package size, the notice period, the make-up rule and its cap, the no-show rule, the expiry window and the teacher's own two register totals?",
  "answers": [
    "how many lessons does this student have left",
    "lesson package tracker",
    "private lesson record sheet",
    "tutoring session log template"
  ],
  "version": "2026-10-11",
  "licence": "Use the arithmetic freely. The figures are yours; nothing is stored here.",
  "method": "A static JSON description of the calculation, plus a worked example. There is no POST endpoint and no server: the page computes in the browser and this file is how the same calculation is read by something that does not look at pages. The arithmetic below is the page's own `compute`, pulled from the page by the id `core`, and the worked example is the state the page ships filled in.",
  "currency": "none. This calculation contains no money figure of any kind: not a lesson price, not an invoice line, not a refund, not a tax figure. It counts lessons, make-ups, hours of notice and weeks, and nothing else. A caller wanting money must do that arithmetic elsewhere.",
  "rounding": {
    "counts": "whole, everywhere. Every input is a count and is read as round(x) clamped at zero from below, in that order, and the endpoint says both numbers in a notice whenever rounding changed what was sent. round is JavaScript's Math.round, which puts a half toward positive infinity rather than away from zero: 7.5 becomes 8, and -0.5 becomes 0 and -1.5 becomes -1 before the clamp at zero takes either to 0. No output is ever a fraction of a lesson, because a part of a lesson is not a lesson: the seven states, the balance and the two halves of the cancellations total are all whole counts, and so is the signed sensitivity figure.",
    "money": "not applicable: this calculation returns no money figure, so it has no cent rule and no negative-half-cent rule to state. The only rounding the engine does is the whole-count rule above, and that rule's behaviour at a negative half is stated there rather than left to be assumed. The one money figure anywhere in the page's folder is the kit's own price inside the checkout block, which is not a lesson figure, is not part of the tally and is not returned here.",
    "percentages": "shareUsedPct is the one percentage this endpoint derives and it is returned at exactly one decimal, as round(x * 10) / 10 of (lessonsBought - balance) / lessonsBought * 100. One decimal is the page's fixed precision rather than a search: a reader multiplying the returned percentage by the package size will land within half a lesson of the numerator, which is the lessons the package has already SPENT: lessonsBought less balance. The page prints that subtraction as its own row, directly above the share, and the row carries the word spent rather than settled, because the page also prints a smaller figure it calls settled - the three settling rows (forfeited by the rule or the cap, forfeited by the no-notice rule, lapsed) added up - and a reader checking the share against that row would be checking it against a different sum. Both rows are printed, so the share can be checked exactly rather than approximately. It is null while the entry is not resolvable."
  },
  "inputs": [
    {
      "name": "studentLabel",
      "type": "string",
      "unit": "none",
      "required": false,
      "range": "any text up to 40 characters; blank and whitespace-only are both accepted",
      "note": "A label for the teacher's own recognition, such as a letter and a lesson slot. THIS IS NOT A NAME FIELD AND MUST NOT BE SENT A STUDENT'S NAME: a private student is often a child. It reaches no arithmetic, is stored nowhere, is sent nowhere, and changes no output; blank or whitespace-only simply prints the package without a label and says so in a notice. One consequence an agent will not see here but a reader does: on the PAGE the label is one of section A's two fields, so typing it takes section A out of the page's worked example and the page then holds its figures until sections B and C are the reader's too. That is a property of the form and not of this endpoint, which has no worked example to hold; the field's own note on the page says so (review round 2, minor 5)."
    },
    {
      "name": "lessonsBought",
      "type": "number",
      "unit": "whole lessons",
      "required": true,
      "range": "1 to 60, step 1",
      "note": "The lessons the family paid for in this package. The page's own field offers 1 to 60; clearing that field reaches 0, which is the first of the five conditions that make an entry not yet resolvable, so 0 is accepted here and returns nulls rather than figures. It is the figure every state adds back to."
    },
    {
      "name": "noticeHours",
      "type": "number",
      "unit": "whole hours",
      "required": false,
      "range": "0 to 168, step 1",
      "note": "How much warning a cancellation must carry to count as a cancellation with notice. It is the rule that sorts the cancellations total into its two halves and it changes nothing else in the arithmetic: the caller does the sorting and sends the result as noNoticeCancellations. It is echoed back as noticeHoursEcho so an agent can print the rule beside the split it produced."
    },
    {
      "name": "makeupPerMissedWithNotice",
      "type": "number",
      "unit": "make-ups per cancellation with notice",
      "required": false,
      "range": "0 or 1, step 1",
      "note": "Whether the teacher's own terms earn a make-up for a cancellation that carried the notice period. Any value above 1 is read as 1. At 0 every cancellation with notice forfeits, and the page names that rule in the row holding those lessons."
    },
    {
      "name": "makeupCap",
      "type": "number",
      "unit": "whole make-ups",
      "required": false,
      "range": "0 to 20, step 1",
      "note": "The most make-ups the teacher allows in one package. At 0 the studio offers none; set at or above the package size it is a cap in name only. Make-ups the cap refuses are returned in forfeitedByRuleOrCap."
    },
    {
      "name": "noShowConsumes",
      "type": "number",
      "unit": "lessons per cancellation without notice",
      "required": false,
      "range": "0 or 1, step 1",
      "note": "Whether a cancellation without notice costs the family the lesson. Any value above 1 is read as 1. This is the one rule with money behind it in the real world, and it is the rule the sensitivity figure prices."
    },
    {
      "name": "expiryWeeks",
      "type": "number",
      "unit": "whole weeks",
      "required": false,
      "range": "0 to 52, step 1",
      "note": "How long the package stays open. 0 means the package never expires, and then windowWeeks is null and lessonsLapsed is 0 whatever weeksElapsed says."
    },
    {
      "name": "breaks[].weeks",
      "type": "number",
      "unit": "whole weeks",
      "required": false,
      "range": "0 to 4, step 1, in each of three slots",
      "note": "The holiday breaks that extend the expiry window, one slot each, sent as breaks: [{weeks}, {weeks}, {weeks}]. The page's own form offers three slots because a teaching year has more than one break and a teacher's terms usually name two of them; a slot she does not use is a break of nothing. Their SUM is the holiday weeks, and that sum runs 0 to 12. Slots past the third are ignored."
    },
    {
      "name": "holidayWeeks",
      "type": "number",
      "unit": "whole weeks",
      "required": false,
      "range": "0 to 12, step 1",
      "note": "The flat spelling of the same figure, for a caller holding one number rather than a list of breaks, and for a ledger column that cannot be mapped onto an array of objects. It is read ONLY when no breaks array is given, and it then produces exactly the state the first break slot reaches on its own. Added to expiryWeeks to make the window, and ignored entirely when expiryWeeks is 0, because a package that never expires has no window to extend."
    },
    {
      "name": "weeksElapsed",
      "type": "number",
      "unit": "whole weeks",
      "required": false,
      "range": "0 to 60, step 1",
      "note": "Weeks since the package started. The package has lapsed when this is above the window; at exactly the window it has not."
    },
    {
      "name": "lessonsUsed",
      "type": "number",
      "unit": "whole lessons",
      "required": true,
      "range": "0 to 60, step 1",
      "note": "Lessons taught at their scheduled time plus any make-up already given: the one running total almost every teaching register holds. It must not be above lessonsBought, and a value that puts the typed figures past the package size is the fifth and last of the five conditions that make an entry not yet resolvable, the one formula[9] lists last. Every formula reference in this file is a zero-based index into the formula array, which is how an agent reads it: formula[9] is the condition line and formula[10] is windowWeeks (review round 2, minor 1: these two references used to count from one)."
    },
    {
      "name": "makeupsGiven",
      "type": "number",
      "unit": "whole lessons",
      "required": false,
      "range": "0 to 60, step 1",
      "note": "How many of lessonsUsed were make-ups rather than lessons at their own slot: a declared-optional split of that total. 0 is the honest answer for a register that does not tag make-ups, and it changes NO output except the split between lessonsTaught and makeupsTaught and the split between makeupsOwed and lessonsUnscheduled. balance, forfeitedByRuleOrCap, forfeitedByNoNotice, lessonsLapsed and shareUsedPct are identical at every value of it, which the page's test suite asserts as an identity over an executed walk rather than as a reassurance. It must not be above lessonsUsed, above makeupCap, or above what the cancellations with notice entitle."
    },
    {
      "name": "cancellations",
      "type": "number",
      "unit": "whole lessons",
      "required": false,
      "range": "0 to 60, step 1",
      "note": "Lessons this student did not attend, whatever the reason and whatever the warning: the other total a register holds."
    },
    {
      "name": "noNoticeCancellations",
      "type": "number",
      "unit": "whole lessons",
      "required": false,
      "range": "0 to 60, step 1",
      "note": "How many of the cancellations carried less warning than noticeHours: a declared-optional split of that total. Unlike makeupsGiven this split DOES move the balance, so the endpoint returns noNoticeSensitivity, the signed change one more cancellation in this column would make, rather than guessing the split. It must not be above cancellations."
    }
  ],
  "outputs": [
    {
      "name": "lessonsTaught",
      "unit": "whole lessons",
      "formula": "lessonsUsed - makeupsGiven; lessons taught at their scheduled time. null while the entry is not resolvable"
    },
    {
      "name": "makeupsTaught",
      "unit": "whole lessons",
      "formula": "makeupsGiven, as sent; lessons taught as a make-up. null while the entry is not resolvable"
    },
    {
      "name": "makeupsOwed",
      "unit": "whole make-ups",
      "formula": "0 when the package has lapsed, and otherwise min(makeupCap, makeupPerMissedWithNotice x cancellationsWithNotice) - makeupsGiven. null while the entry is not resolvable"
    },
    {
      "name": "lessonsUnscheduled",
      "unit": "whole lessons",
      "formula": "0 when the package has lapsed, and otherwise lessonsBought - lessonsUsed - forfeitedByRuleOrCap - forfeitedByNoNotice - makeupsOwed: the residual, every lesson no named rule has touched. null while the entry is not resolvable"
    },
    {
      "name": "forfeitedByRuleOrCap",
      "unit": "whole lessons",
      "formula": "(makeupPerMissedWithNotice x cancellationsWithNotice - min(makeupCap, makeupPerMissedWithNotice x cancellationsWithNotice)) + (cancellationsWithNotice when makeupPerMissedWithNotice is 0, else 0): the entitlements the cap refused, plus every cancellation with notice when the terms earn no make-up. null while the entry is not resolvable"
    },
    {
      "name": "forfeitedByNoNotice",
      "unit": "whole lessons",
      "formula": "noShowConsumes x noNoticeCancellations. null while the entry is not resolvable"
    },
    {
      "name": "lessonsLapsed",
      "unit": "whole lessons",
      "formula": "makeupsOwed + lessonsUnscheduled before the window test, when expiryWeeks is above 0 and weeksElapsed is above expiryWeeks + holidayWeeks; otherwise 0. null while the entry is not resolvable"
    },
    {
      "name": "balance",
      "unit": "whole lessons",
      "formula": "lessonsBought - lessonsUsed - forfeitedByRuleOrCap - forfeitedByNoNotice - lessonsLapsed, which is also makeupsOwed + lessonsUnscheduled. This is the page's one claim: lessons bought less lessons taken less make-ups used, under the rules set on the page, with the working shown. null while the entry is not resolvable"
    },
    {
      "name": "cancellationsWithNotice",
      "unit": "whole lessons",
      "formula": "cancellations - noNoticeCancellations; computed here and never typed. null while the entry is not resolvable"
    },
    {
      "name": "cancellationsWithoutNotice",
      "unit": "whole lessons",
      "formula": "noNoticeCancellations, as sent; the other half of the same total. null while the entry is not resolvable"
    },
    {
      "name": "shareUsedPct",
      "unit": "per cent, one decimal, 0 to 100",
      "formula": "round1((lessonsBought - balance) / lessonsBought * 100): the share of the package already used, so that a lesson forfeited or lapsed counts as used. null while the entry is not resolvable"
    },
    {
      "name": "windowWeeks",
      "unit": "whole weeks",
      "formula": "expiryWeeks + holidayWeeks when expiryWeeks is above 0, and null when it is 0, which means the package never expires. It is returned whether or not the entry resolves, because it depends on no register figure"
    },
    {
      "name": "noticeHoursEcho",
      "unit": "whole hours",
      "formula": "noticeHours, as sent and rounded to a whole hour: the rule that sorted the two cancellation columns, echoed back so it can be printed beside them. It is returned whether or not the entry resolves"
    },
    {
      "name": "noNoticeSensitivity",
      "unit": "whole lessons, signed",
      "formula": "the balance with noNoticeCancellations one higher and cancellations unchanged, less the balance as sent. SIGNED, and an executed walk of 397,096 valid states found it takes exactly -1, 0 and +1. null FIRST, when cancellationsWithNotice is 0, because there is no cancellation with notice to move - this clause is checked before every clause below it, including the lapse one, and the state where both hold (a lapsed package in which every cancellation came without notice) returns null, not 0, on this clause alone (P-014 closing round, M1: the two clauses used to be stated in the other order with no precedence said out loud, which cost a reviewer's reimplementation a re-read of the engine to settle; the engine was already correct). null also while the entry is not resolvable. Short of those two, it is 0 in FOUR cases, not three, and the first of them sits above the other three: 0 whenever the package has lapsed, because the balance is 0 with the extra cancellation and 0 without it, whatever the rules are set to (review round 6, B6-2: every clause here used to be written as though the window were always open, so the -1 clause below claimed a family this endpoint returns 0 for - a lapsed package with a no-show rule and a cap with room satisfied its words to the letter); and then, inside the window, 0 where the no-notice rule costs nothing and the cap had room, or where the no-show consumes a lesson and the make-up it replaces was one the cap or the make-up rule would have refused (review round 2, minor 1's companion: this clause said \"the cap\" alone, and the page's own reason sentence said the same thing in a studio whose terms earn no make-up and whose cap had therefore refused nothing - B2-1). Inside the window it is -1 where a no-show consumes the lesson and the cap had room for the make-up it replaces, and +1 where the no-notice rule costs nothing and the cap or the make-up rule would have refused that make-up, because moving the cancellation out of the with-notice column is what gives the lesson back"
    }
  ],
  "formula": [
    "every input is read as round(x) clamped at zero from below, and the two rules makeupPerMissedWithNotice and noShowConsumes are then clamped at 1 from above, so every figure below is a whole count",
    "holidayWeeks = the sum of the first three breaks[].weeks when a breaks array is given, and the flat holidayWeeks field when it is not; nothing else on the page or in this endpoint writes it",
    "cancellationsWithNotice = cancellations - noNoticeCancellations",
    "entitled = makeupPerMissedWithNotice x cancellationsWithNotice",
    "allowed = min(makeupCap, entitled)",
    "forfeitedByRuleOrCap = (entitled - allowed) + (cancellationsWithNotice when makeupPerMissedWithNotice is 0, else 0)",
    "forfeitedByNoNotice = noShowConsumes x noNoticeCancellations",
    "lessonsTaught = lessonsUsed - makeupsGiven",
    "accounted = lessonsUsed + forfeitedByRuleOrCap + forfeitedByNoNotice + (allowed - makeupsGiven)",
    "an entry is NOT YET RESOLVABLE when any of these five conditions holds, and then every output above except windowWeeks and noticeHoursEcho is null and the page prints no number in its four headline rows and no count in its table: lessonsBought is 0; a split stands above the total it splits (makeupsGiven above lessonsUsed, or noNoticeCancellations above cancellations); makeupsGiven is above makeupCap; makeupsGiven is above entitled, which also covers make-ups given while makeupPerMissedWithNotice is 0; or accounted is above lessonsBought",
    "windowWeeks = expiryWeeks + holidayWeeks when expiryWeeks is above 0, and null otherwise, so three one-week breaks and one three-week break make the same window",
    "the package has lapsed when expiryWeeks is above 0 AND weeksElapsed is above windowWeeks; at exactly windowWeeks it has not",
    "remaining = lessonsBought - lessonsUsed - forfeitedByRuleOrCap - forfeitedByNoNotice",
    "makeupsOwed = 0 and lessonsUnscheduled = 0 and lessonsLapsed = remaining when the package has lapsed; otherwise makeupsOwed = allowed - makeupsGiven, lessonsUnscheduled = remaining - makeupsOwed and lessonsLapsed = 0",
    "balance = makeupsOwed + lessonsUnscheduled, which equals lessonsBought - lessonsUsed - forfeitedByRuleOrCap - forfeitedByNoNotice - lessonsLapsed in every resolvable state",
    "shareUsedPct = round1((lessonsBought - balance) / lessonsBought * 100)",
    "noNoticeSensitivity = the balance recomputed with noNoticeCancellations one higher and cancellations unchanged, less the balance; null FIRST, when cancellationsWithNotice is 0, because there is no cancellation with notice to move - checked before the lapse clause that follows, so a lapsed package in which every cancellation came without notice returns null on this clause alone; 0 whenever the package has lapsed, in every other case, because that balance is 0 either way"
  ],
  "identities": [
    "lessonsTaught + makeupsTaught + makeupsOwed + lessonsUnscheduled + forfeitedByRuleOrCap + forfeitedByNoNotice + lessonsLapsed equals lessonsBought, exactly, in every resolvable state: the seven states are mutually exclusive and they partition the package",
    "balance equals makeupsOwed + lessonsUnscheduled, and equals lessonsBought - lessonsUsed - forfeitedByRuleOrCap - forfeitedByNoNotice - lessonsLapsed",
    "cancellationsWithNotice + cancellationsWithoutNotice equals cancellations",
    "lessonsTaught + makeupsTaught equals lessonsUsed",
    "changing makeupsGiven with lessonsUsed held fixed leaves balance, forfeitedByRuleOrCap, forfeitedByNoNotice, lessonsLapsed and shareUsedPct unchanged; only the lessonsTaught/makeupsTaught split and the makeupsOwed/lessonsUnscheduled split move",
    "no returned count is ever negative, and shareUsedPct is never above 100, in any resolvable state; noNoticeSensitivity is the one signed figure and takes only -1, 0 and +1",
    "lessonsLapsed is 0 whenever expiryWeeks is 0, and balance is 0 whenever the package has lapsed",
    "a caller sending holidayWeeks as a flat figure and a caller sending a breaks array whose first slot holds that figure get identical answers in every field, which the page's test suite asserts over a swept family rather than at one state"
  ],
  "example": {
    "note": "The package the page ships filled in: a ten-lesson block, a 24-hour notice period, one make-up per cancellation with notice capped at two, a no-show that consumes the lesson, a fourteen-week window with one break of a week named in the first of the three slots, eight weeks elapsed, seven lessons used of which one was a make-up, and three cancellations of which one came without notice. Sending holidayWeeks: 1 with no breaks array returns the same fourteen figures.",
    "inputs": {
      "studentLabel": "Student A, Tue 4pm",
      "lessonsBought": 10,
      "noticeHours": 24,
      "makeupPerMissedWithNotice": 1,
      "makeupCap": 2,
      "noShowConsumes": 1,
      "expiryWeeks": 14,
      "breaks": [{ "weeks": 1 }, { "weeks": 0 }, { "weeks": 0 }],
      "weeksElapsed": 8,
      "lessonsUsed": 7,
      "makeupsGiven": 1,
      "cancellations": 3,
      "noNoticeCancellations": 1
    },
    "outputs": {
      "lessonsTaught": 6,
      "makeupsTaught": 1,
      "makeupsOwed": 1,
      "lessonsUnscheduled": 1,
      "forfeitedByRuleOrCap": 0,
      "forfeitedByNoNotice": 1,
      "lessonsLapsed": 0,
      "balance": 2,
      "cancellationsWithNotice": 2,
      "cancellationsWithoutNotice": 1,
      "shareUsedPct": 80,
      "windowWeeks": 15,
      "noticeHoursEcho": 24,
      "noNoticeSensitivity": -1
    }
  },
  "not_covered": [
    "what the teacher's own terms actually say. Every rule here is sent by the caller and none of them is a default, a recommendation or a typical figure for the trade",
    "whether a cancellation really carried the warning it was logged with, which is the judgment the whole split rests on and the one thing no arithmetic settles",
    "any figure in money: a lesson price, what a package cost, what a make-up is worth, a refund, an invoice, income or tax. This calculation counts lessons and nothing else",
    "anything about the student: a name, an age, progress, practice done, or what was worked on. Nothing of that kind is an input here and nothing of that kind is returned",
    "whether a make-up policy is fair, lawful, standard or enforceable, which is a question about contracts and consumer law and not about counting",
    "more than one package at a time. One call is one package for one student; a studio is many calls, or the ledger the kit beside the page carries",
    "the page's worked-example hold, which is a property of the FORM and not of the arithmetic. Every field on the page ships filled with a worked example, so the page holds back its own seven states, its balance working and its four headline figures while some of its three input blocks are the reader's and some are still the example - a count worked from her package and a stranger's rules. Lessons taught at their scheduled time is held with them for a different reason, which review round 4 made the page say out loud (B4-3): both its operands are typed in the same input block, so it is not a mixed-provenance figure at all, but it is one of the seven states and the table holds all seven. This endpoint has no form and no shipped state to hold: it answers whatever complete state a caller sends, and the only thing it withholds is a figure for an entry that is not yet resolvable, which the five conditions in formula[9] define, counting the formula array from zero"
  ],
  "no_advice": "This endpoint returns arithmetic on figures supplied to it. It recommends no notice period, no make-up cap, no expiry window and no charge for a missed lesson, it recommends no studio policy and no application, and it returns nothing about a student."
}
