{
  "name": "Per-property cleaning turnover record",
  "page": "https://otherworldlabs.com/cleaning-sign-off-checklist/",
  "endpoint": "https://otherworldlabs.com/cleaning-sign-off-checklist/api.json",
  "question": "For one rental property over one month, how many of that property's listed tasks were ticked across the turnovers that are finished, how many items were flagged for the owner, how did the minutes on site run against the minutes that property allows, and what does the month come to at the agreed rate?",
  "answers": "one property at a time; the page keeps no history and this endpoint stores nothing",
  "version": "2026-10-13a",
  "licence": "The arithmetic is not ours to license. Reuse the formulas; the page is free and needs no attribution.",
  "method": "static description plus a worked example; the same formulas run in the page in the reader's browser, with nothing sent anywhere",
  "currency": "whatever the rate is entered in; the page does no conversion and attaches no currency to a figure",
  "scope": "A record of tasks ticked and time on site. It makes no judgment about how clean anything is or about the state of the property, it names no cleaning agent, dilution or dwell time, and it is not a compliance record for a regulated premises. It is not advice on rates or contracts.",
  "rounding": {
    "counts": "whole; a typed fraction is rounded to the nearest whole, once the figure is known to be the one the engine will use. The two levels disagree about WHEN that is, which is the order a fraction and its range are tested in, and C-12 (closing round) found this paragraph stating only one order for both: a ROW field (turnsCovered, ticks, flagged) is rounded FIRST and is then brought to the nearest end of its range, with both numbers said when it is; a STANDING field (tasksListed, minutesAllowed, rate) is tested against the range the control offers BEFORE it is rounded at all - the `standing` block below states that rule and this paragraph now agrees with it rather than describing a single order for both levels. The practical difference is the fractional window right at a floor or a ceiling: a standing figure of 60.4 tasks (range 1 to 60) is over the top before it is ever rounded, brought down to 60 with both numbers said; rounded first, it would round to 60 and silently miss its own ceiling. A standing field over the top is brought DOWN to the top of its range with both numbers said; one below the smallest figure its control offers, which is every figure of zero or less and every figure in the open fractional window between zero and that floor, reads as NOT GIVEN - the page then prints no figure that would need it and says what the box reads as, rather than working from a figure no control offers.",
    "money": "two decimals by round2(x) = sign(x) * Math.round(abs(x) * 100) / 100 on the double, which puts a half AWAY FROM ZERO: round2(-0.125) is -0.13 and not -0.12, which is where it differs from Math.round(x * 100) / 100. No input to this calculation can make amountDue negative - the rate, the minutes and the turns covered are all held at or above zero - so the negative half-cent is not reachable from the page's own controls; the rule is stated because it is what the engine does, not because the page prints it. round2 is applied once, to the amount, and the amount is the only figure in this answer that is not a whole number of tasks, items, turns or minutes. (C-2, closing round: before this round the engine also applied round2 to a typed rate that was inside its range, a second rounding this paragraph never admitted to, and the rounded rate is what let 0.005 - a figure below the 0.01 floor - round up to meet it and be used as given. The rate is no longer rounded at this stage; a rate inside its range is carried at the precision it was typed and round2 runs only once, on the amount, exactly as this paragraph says.)",
    "percentages": "ticksOfListedPct is printed at the fewest decimals, minimum one, that make the percentage reach ticksAcrossTurns from tasksListedAcrossTurns at that count's own precision; the search runs from one decimal to six and the figure returned is that rounded percentage rather than the exact quotient, so a reader who recomputes the count from the figure shown gets the count. Where no precision up to six reaches it, the percentage is returned at six decimals."
  },
  "standing": {
    "fields": ["tasksListed", "minutesAllowed", "rate"],
    "rule": "One rule, and the page's engine is written from this paragraph. (1) Blank, whitespace-only or anything that does not read as a number: NOT GIVEN. (2) A figure over the top of the range the control offers (60 tasks, 480 minutes, 1000.00): brought down to that top, used at that top, and the page prints a note naming BOTH numbers - what you typed and what the record uses - so no line says 'as entered' beside a figure nobody entered. (3) A figure below the smallest figure the control offers (1 task, 15 minutes, 0.01): NOT GIVEN, with a note naming what the box reads as. Zero and every negative figure fall under (3). There is no fourth case: the page never computes from a figure between zero and the floor, because no control offers one. This test is against the figure AS TYPED, before any rounding: a figure is over the top, or under the floor, however close its fraction sits to the boundary - 1000.004 is over the 1000.00 top and 0.005 is under the 0.01 floor, each exactly as a figure ten times as far from the boundary would be, because rounding never runs before this test (C-2, C-3, closing round).",
    "consequence": "Where a standing figure is not given, every output that would need it is null: tasksListed withholds tasksListedAcrossTurns and ticksOfListedPct; minutesAllowed withholds minutesAllowedForTurns and minutesAgainstAllowed; rate withholds amountDue. The outputs that do not need it are unaffected, so a month with no rate still prints its turnovers, its ticks, its flags and its minutes.",
    "history": "Round 1 of the review found these three fields with no stated rule: the engine clamped them into [0, max] and then read a resulting zero as absent, so 3 minutes was used as 3 although the control starts at 15, 70 tasks was used as 60 with the line beside it reading 'as entered', and a rate of -5 printed the sentence an empty box prints. 91 disagreements over 38 states between this endpoint and the page came from that one gap. The rule above was the fix for that round. The closing round's reviewer (REVIEW-closing-1.md C-1, C-2, C-3) found two more gaps the rule above did not close: (a) the glue between the DOM and the engine read an empty standing box as the literal number 0 before the engine ever saw it, so the blank case above was unreachable from the shipped page no matter how correctly the engine itself was written; (b) the engine tested this rule's (2) and (3) against the figure AFTER rounding it, not before, so a figure in the fractional window right at the floor or the ceiling was rounded across the boundary first and then tested on the wrong side of it, which is how a rate of 0.005 - below the floor - printed an amount at all. Both are fixed in the page; this paragraph and the rule above describe the engine as it now is."
  },

  "inputs": [
    { "name": "propertyLabel", "type": "string", "unit": "none", "required": false, "note": "A short label the cleaner uses for the property. Free text, cut at 40 characters, and replaced by 'This property' when blank or only whitespace. Not an address, and nothing is computed from it. Door, lockbox and alarm codes belong nowhere near it: the page prints the label." },
    { "name": "tasksListed", "type": "integer", "unit": "tasks", "required": false, "range": "1 to 60 from the page's own control, step 1", "note": "How many tasks the room-by-room list for this property carries. The cleaner's or the owner's figure; the page supplies no list and has no view on what should be on one. A standing field, so the `standing` block's rule governs it: a fraction is rounded, over 60 is brought down to 60 with both numbers said, and under 1 - zero and every negative included - reads as not given. Not given, no share of the list is returned anywhere: ticksOfListedPct and tasksListedAcrossTurns are both null." },
    { "name": "minutesAllowed", "type": "integer", "unit": "minutes", "required": false, "range": "15 to 480 from the page's own control, step 5", "note": "The minutes one turnover at this property is allowed, as agreed or as set by the cleaner. Not a target this calculation sets and not a limit it enforces. A standing field, so the `standing` block's rule governs it: over 480 is brought down to 480 with both numbers said, and under 15 - zero and every negative included - reads as not given, so a typed 3 yields no allowance figure rather than an allowance of 3. Not given, minutesAllowedForTurns and minutesAgainstAllowed are both null." },
    { "name": "rateBasis", "type": "string", "unit": "none", "required": false, "range": "the page's control offers exactly 'per turn' and 'per hour'", "note": "Whether the rate is written for the turnover or for the hour. Any value other than 'per hour', compared after trimming and lowercasing, reads as 'per turn', which is the page's shipped value; the blank is not reachable from the control and is handled that way rather than refused. Whether independent cleaners invoice per turn or per hour is not known to us, which is why this is a field and not an assumption." },
    { "name": "rate", "type": "number", "unit": "money", "required": false, "range": "0.01 to 1000.00 from the page's own control, step 0.01", "note": "The rate on the basis above. A standing field, so the `standing` block's rule governs it: over 1000.00 is brought down to 1000.00 with both numbers said, and anything under 0.01 - zero, a negative, a blank - reads as not given. A rate of zero is therefore NOT a rate of zero: amountDue is null and no amount is printed, which is why the control's own floor is 0.01 and not 0.00." },
    { "name": "turns[].date", "type": "string", "unit": "none", "required": false, "note": "The date as the cleaner writes it. Free text, cut at 24 characters; nothing is computed from it. It is joined to the card's own position to make the row's name, so a note about a row names something the form can be found by." },
    { "name": "turns[].inTime", "type": "string", "unit": "clock time", "required": false, "range": "HH:MM on a 24-hour clock, one or two digits of hour, 00 to 23, and 00 to 59 minutes", "note": "The time the cleaner went in. Anything that does not match that shape - blank, whitespace, a word, 24:30 - reads as not given." },
    { "name": "turns[].outTime", "type": "string", "unit": "clock time", "required": false, "range": "HH:MM on a 24-hour clock, as inTime", "note": "The time the cleaner came out. The pair gives minutes only when both read as a time AND the out time is strictly later than the in time: no turnover is assumed to run past midnight, so an out time at or before the in time yields nothing from the clock and the row falls back to minutesOnSite." },
    { "name": "turns[].minutesOnSite", "type": "string", "unit": "minutes", "required": false, "range": "a whole number from 0 to 14880, which is the 480-minute allowance times the 31-turn maximum", "note": "The ALTERNATIVE to the clock pair and not a third required field, for the cleaner who kept only the month's total. A row takes its minutes from the clock pair when that pair yields minutes and from this figure otherwise; a row carrying both takes the clock pair and the page says in the row that it did. Blank, whitespace, a fraction, a negative and anything above 14880 all read as not given, and so does anything that is not a whole number of minutes written in plain digits - hexadecimal ('0x10'), exponential ('1e3') or a leading plus sign ('+5') are numeric to a programming language and not to a cleaner, and C-9 (closing round) found the engine reading them anyway. It is a string rather than a number because the blank is a state the calculation answers." },
    { "name": "turns[].turnsCovered", "type": "integer", "unit": "turnovers", "required": false, "range": "1 to 31 from the page's own control, step 1", "note": "How many turnovers this one row stands for. Normally 1. Blank reads as 1. This is the figure that lets a cleaner who kept only the month's totals enter one row for the whole month, and it is what tasksListed and minutesAllowed are multiplied by before the row is scored." },
    { "name": "turns[].ticks", "type": "integer", "unit": "tasks", "required": false, "range": "0 to 1860 from the page's own control, step 1, which is 60 tasks times the 31-turn maximum", "note": "How many of the listed tasks were ticked across the turnovers this row covers. Blank reads as nothing. Where it is more than tasksListed times turnsCovered the two figures cannot both be right: the page prints no share for that row, names both figures, and returns ticksOfListedPct as null for the whole month." },
    { "name": "turns[].flagged", "type": "integer", "unit": "items", "required": false, "range": "0 to 930 from the page's own control, step 1, which is 30 items times the 31-turn maximum", "note": "How many items were flagged for the owner across the turnovers this row covers: something broken, missing or needing a restock. A count of notes. No cost is asked for and none is modelled." }
  ],
  "row_rules": [
    "A row EXISTS once any one of its seven fields has something in it. The page ships twelve cards; a card nobody has touched is not a row and is not waiting for anything.",
    "A row is FINISHED once its minutes are known: from the clock pair where that pair yields minutes, from minutesOnSite otherwise.",
    "A row that is not finished is OPEN. It contributes no minutes, no ticks, no flagged items and no turnovers to the month, and the page prints no figure of its own for it.",
    "While any row is open every month figure is returned as it stands and the page marks each one; while NO row is finished every month figure except rowsStarted, rowsOpen and finishedTurns is null, and finishedTurns is 0 - the sum over an empty set of finished rows, as the outputs list says. An earlier wording of this rule left finishedTurns out of the exception and so contradicted the outputs list; the engine returns 0 and always has."
  ],
  "outputs": [
    { "name": "rowsStarted", "unit": "rows", "formula": "the number of turn rows with something in at least one field" },
    { "name": "rowsOpen", "unit": "rows", "formula": "rowsStarted less the rows whose minutes are known from either source" },
    { "name": "finishedTurns", "unit": "turnovers", "formula": "sum over the finished rows of turnsCovered; 0 when no row is finished" },
    { "name": "minutesOnSite", "unit": "minutes", "formula": "sum over the finished rows of that row's minutes, each from its clock pair where the pair yields minutes and from its typed figure otherwise; null when no row is finished" },
    { "name": "minutesAllowedForTurns", "unit": "minutes", "formula": "minutesAllowed x finishedTurns; null when no row is finished or no allowance was given" },
    { "name": "minutesAgainstAllowed", "unit": "minutes", "formula": "minutesOnSite - minutesAllowedForTurns; SIGNED, and returned negative when the finished turnovers took less time than the allowance for them; null when no row is finished or no allowance was given" },
    { "name": "ticksAcrossTurns", "unit": "tasks", "formula": "sum over the finished rows of ticks; null when no row is finished. Unlike ticksOfListedPct, this raw total is NOT withheld when a finished row is over-ticked or when no task count was given - only the share is, because a share of nothing or a share with a disagreeing numerator is the figure that cannot be printed, not the tick count itself. C-11 (closing round): a reader who reproduces the page from this endpoint gets a figure in those two states that the page's own row 7 and month line print as a dash; the dash is the page's choice to show working rather than a bare count, not a disagreement with this endpoint." },
    { "name": "tasksListedAcrossTurns", "unit": "tasks", "formula": "tasksListed x finishedTurns; null when no row is finished or no task count was given" },
    { "name": "ticksOfListedPct", "unit": "per cent", "formula": "ticksAcrossTurns / tasksListedAcrossTurns x 100, printed at the fewest decimals that reach ticksAcrossTurns; null when no row is finished, when no task count was given, and null as well whenever ANY finished row has more ticks than tasksListed x that row's turnsCovered - one tick count that cannot be a count of this list withholds the whole month's share, on the same rule the row's own share uses" },
    { "name": "itemsFlagged", "unit": "items", "formula": "sum over the finished rows of flagged; null when no row is finished" },
    { "name": "amountDue", "unit": "money", "formula": "on the per-turn basis, round2(finishedTurns x rate); on the per-hour basis, round2(minutesOnSite / 60 x rate), which is a timesheet total and moves with the clock rather than with the count of turnovers; null when no row is finished or no rate was given" }
  ],
  "formula": [
    "the three standing fields first: tasksListed, minutesAllowed and rate are read by the `standing` rule, and each one is either a figure inside the range its control offers or not given",
    "for each row: it exists if any of date, inTime, outTime, minutesOnSite, a non-zero ticks, a non-zero flagged, or a turnsCovered above 1 has something in it",
    "for each row: clockMinutes = outTime - inTime where both read as HH:MM and outTime is strictly later; otherwise nothing",
    "for each row: minutes = clockMinutes where it exists, else the minutesOnSite figure where it reads as a whole number from 0 to 14880, else nothing (the row is open)",
    "for each row: covered = turnsCovered, blank reading as 1, rounded and brought inside 1 to 31",
    "for each row: tasksListedForRow = tasksListed x covered; minutesAllowedForRow = minutesAllowed x covered",
    "for each row: overTicked = ticks > tasksListedForRow; where it is true the row's share is withheld and so is the month's",
    "for each row: rowShare = ticks / tasksListedForRow x 100, at the fewest decimals that reach ticks",
    "for each row: againstAllowed = minutes - minutesAllowedForRow, signed",
    "finishedTurns, minutesOnSite, ticksAcrossTurns and itemsFlagged are the column footings of the FINISHED rows, added as printed",
    "tasksListedAcrossTurns = tasksListed x finishedTurns; minutesAllowedForTurns = minutesAllowed x finishedTurns",
    "minutesAgainstAllowed = minutesOnSite - minutesAllowedForTurns",
    "ticksOfListedPct = ticksAcrossTurns / tasksListedAcrossTurns x 100, withheld as null when any finished row is overTicked",
    "amountDue = round2(finishedTurns x rate) on the per-turn basis, round2(minutesOnSite / 60 x rate) on the per-hour basis"
  ],
  "identities": [
    "finishedTurns is the sum of turnsCovered over the finished rows and is never the count of those rows unless every one of them covers exactly one turnover",
    "minutesAllowedForTurns equals minutesAllowed x finishedTurns, which is the two-level rule: the month is footed from the standing row and the finished turnovers, not from the rows' own allowances added up",
    "ticksAcrossTurns is at most tasksListedAcrossTurns wherever ticksOfListedPct is not null, which is what makes that percentage bounded 0 to 100",
    "rowsStarted equals rowsOpen plus the rows whose minutes are known",
    "all four are asserted by the page's own test harness over every state the shared generator reaches"
  ],
  "example": {
    "note": "the month the page ships filled in, so this endpoint and the page answer with the same figures. Three rows, the third of them open: its time out is missing, so it has no minutes, and nothing of it is counted.",
    "inputs": {
      "propertyLabel": "Sea Lane flat",
      "tasksListed": 28,
      "minutesAllowed": 150,
      "rateBasis": "per turn",
      "rate": 65.00,
      "turns": [
        { "date": "3 Oct", "inTime": "09:30", "outTime": "12:05", "minutesOnSite": "", "turnsCovered": 1, "ticks": 28, "flagged": 0 },
        { "date": "9 Oct", "inTime": "10:00", "outTime": "13:10", "minutesOnSite": "", "turnsCovered": 1, "ticks": 26, "flagged": 2 },
        { "date": "16 Oct", "inTime": "09:45", "outTime": "", "minutesOnSite": "", "turnsCovered": "", "ticks": "", "flagged": "" }
      ]
    },
    "outputs": {
      "rowsStarted": 3,
      "rowsOpen": 1,
      "finishedTurns": 2,
      "minutesOnSite": 345,
      "minutesAllowedForTurns": 300,
      "minutesAgainstAllowed": 45,
      "ticksAcrossTurns": 54,
      "tasksListedAcrossTurns": 56,
      "ticksOfListedPct": 96.4,
      "itemsFlagged": 2,
      "amountDue": 130
    }
  },
  "not_covered": [
    "The tasks themselves. This calculation counts them and does not hold them: there is no list in it, no room-by-room template, and no view on what a turnover at any property should include.",
    "Photographs, timestamps and the signed reports that turnover software sells. A static page has no camera and no clock of its own, and a printed signature line says only what it says: which of the listed tasks were ticked.",
    "The state of the property. A flagged item is a note that something is broken, missing or needs a restock, with no cost, no fault and no assessment attached, and the page asks for none. What the owner does about it is outside this record.",
    "Anything to do with letting the property or with the people who stay in it. This is a cleaning record for the person who cleans and the owner who hands over a list, and it holds no guest name, no occupancy and nothing about money between an owner and anybody but the cleaner.",
    "Any history. One property for one month; the page stores nothing between visits and this endpoint stores nothing at all.",
    "Travel time, materials, consumables bought, mileage, tax and every other cost of the work. The amount due is the rate on the basis it is written on, and nothing else.",
    "Whether the rate should be per turnover or per hour, and what either should be. The basis is a field because the answer is not known to us, and no rate is suggested."
  ],
  "no_advice": "This endpoint returns arithmetic on figures supplied to it. It recommends no rate, no task list, no action about a flagged item, and it makes no judgment about any property or any person's work."
}
