> ## Documentation Index
> Fetch the complete documentation index at: https://docs.twine.se/llms.txt
> Use this file to discover all available pages before exploring further.

# Dated property patterns

> A compact string format for encoding dated property values into a single text field

export const DatedPropertyPatternBuilder = () => {
  const emptyRow = () => ({
    value: '',
    start: '',
    end: '',
    id: ''
  });
  const RE_SANITIZE_ID = new RegExp('[\\s/,#"]', 'g');
  const RE_FORBIDDEN_ID = new RegExp('[\\s/,#]');
  const RE_INT = new RegExp('^-?\\d+$');
  const RE_DEC = new RegExp('^-?\\d+\\.\\d+$');
  const RE_PCT = new RegExp('^\\d+(\\.\\d+)?%$');
  const RE_HOURS = new RegExp('^\\d+(\\.\\d+)?[hH]$');
  const RE_HM = new RegExp('^\\d+:\\d+$');
  const RE_DATE = new RegExp('^\\d{4}-\\d{1,2}-\\d{1,2}$');
  const RE_DATE_STRICT = new RegExp('^\\d{4}-\\d{2}-\\d{2}$');
  const RE_DQUOTE = new RegExp('"', 'g');
  const RE_NEEDS_QUOTE = new RegExp('[,/]');
  const RE_UNICODE_WS = new RegExp('[\\u00A0\\u2007\\u2009\\u202F\\u200B]', 'g');
  const RE_DIGIT = new RegExp('\\d');
  const RE_TRAILING_DIGIT = new RegExp('\\d$');
  const sanitizeId = raw => raw.replace(RE_SANITIZE_ID, '');
  const isNumericLike = v => {
    const n = v.replace(',', '.');
    return RE_INT.test(n) || RE_DEC.test(n) || RE_PCT.test(n) || RE_HOURS.test(n) || RE_HM.test(v) || RE_DATE.test(v);
  };
  const emitValue = raw => {
    if (raw === '') return '';
    const trimmed = raw.trim();
    if (isNumericLike(trimmed)) return trimmed.replace(',', '.');
    const stripped = raw.replace(RE_DQUOTE, '');
    const needsQuote = RE_NEEDS_QUOTE.test(stripped) || stripped !== stripped.trim() || stripped === '';
    return needsQuote ? `"${stripped}"` : stripped;
  };
  const renderRow = row => {
    const v = emitValue(row.value);
    if (v === '') return null;
    const start = row.start.trim();
    const end = row.end.trim();
    const id = sanitizeId(row.id);
    const idSuffix = id ? `#${id}` : '';
    if (!start && !end) return `${v}${idSuffix}`;
    if (start && !end) return `${v}, ${start}${idSuffix}`;
    if (!start && end) return `${v}, , ${end}${idSuffix}`;
    return `${v}, ${start}, ${end}${idSuffix}`;
  };
  const computePattern = rs => rs.map(renderRow).filter(p => p !== null).join(' / ');
  const pad2 = n => n < 10 ? `0${n}` : String(n);
  const formatDate = (y, m, d) => {
    const date = new Date(Date.UTC(y, m - 1, d));
    if (date.getUTCFullYear() !== y || date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) {
      return null;
    }
    return `${y}-${pad2(m)}-${pad2(d)}`;
  };
  const DATE_FORMATS = [{
    re: new RegExp('^(\\d{4})-(\\d{1,2})-(\\d{1,2})$'),
    yearOffset: 0
  }, {
    re: new RegExp('^(\\d{4}) (\\d{1,2}) (\\d{1,2})$'),
    yearOffset: 0
  }, {
    re: new RegExp('^(\\d{4})(\\d{2})(\\d{2})$'),
    yearOffset: 0
  }, {
    re: new RegExp('^(\\d{2})-(\\d{1,2})-(\\d{1,2})$'),
    yearOffset: 2000
  }, {
    re: new RegExp('^(\\d{2}) (\\d{1,2}) (\\d{1,2})$'),
    yearOffset: 2000
  }, {
    re: new RegExp('^(\\d{2})(\\d{2})(\\d{2})$'),
    yearOffset: 2000
  }];
  const canonicalizeDate = s => {
    const t = s.trim();
    for (let i = 0; i < DATE_FORMATS.length; i++) {
      const m = t.match(DATE_FORMATS[i].re);
      if (m) {
        return formatDate(DATE_FORMATS[i].yearOffset + parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10));
      }
    }
    return null;
  };
  const normalizeValue = raw => {
    const trimmed = raw.trim();
    if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
      return trimmed.slice(1, -1);
    }
    if (isNumericLike(trimmed)) return trimmed.replace(',', '.');
    return trimmed;
  };
  const isDecimalComma = (buf, s, pos) => {
    if (buf === '' || !RE_TRAILING_DIGIT.test(buf)) return false;
    let i = pos + 1;
    let digits = 0;
    while (i < s.length && RE_DIGIT.test(s[i])) {
      digits++;
      i++;
    }
    if (digits === 0) return false;
    return i >= s.length || s[i] === ',';
  };
  const splitParts = s => {
    const parts = [];
    let buf = '';
    let inQuote = false;
    for (let i = 0; i < s.length; i++) {
      const c = s[i];
      if (c === '"') {
        buf += c;
        inQuote = !inQuote;
      } else if (c === ',' && !inQuote) {
        if (parts.length === 0 && isDecimalComma(buf, s, i)) {
          buf += c;
        } else {
          parts.push(buf.trim());
          buf = '';
        }
      } else {
        buf += c;
      }
    }
    if (inQuote) return {
      ok: false,
      error: 'Unterminated quoted string'
    };
    parts.push(buf.trim());
    return {
      ok: true,
      parts
    };
  };
  const splitSegments = s => {
    const result = [];
    let buf = '';
    let inQuote = false;
    for (let i = 0; i < s.length; i++) {
      const c = s[i];
      if (c === '"') {
        buf += c;
        inQuote = !inQuote;
      } else if (c === '/' && !inQuote) {
        result.push(buf.trim());
        buf = '';
      } else {
        buf += c;
      }
    }
    if (inQuote) return {
      ok: false,
      error: 'Unterminated quoted string'
    };
    result.push(buf.trim());
    return {
      ok: true,
      segments: result
    };
  };
  const parseSegment = (seg, label) => {
    if (seg === '') return {
      ok: false,
      error: `${label}: empty segment`
    };
    let idStart = -1;
    let inQuote = false;
    for (let i = 0; i < seg.length; i++) {
      const c = seg[i];
      if (c === '"') inQuote = !inQuote; else if (c === '#' && !inQuote) {
        idStart = i;
        break;
      }
    }
    let id = '';
    let main = seg;
    if (idStart >= 0) {
      id = seg.slice(idStart + 1).trim();
      main = seg.slice(0, idStart).trimEnd();
      if (id === '') return {
        ok: false,
        error: `${label}: empty ID after '#'`
      };
      if (RE_FORBIDDEN_ID.test(id)) {
        return {
          ok: false,
          error: `${label}: invalid character in ID '${id}'`
        };
      }
    }
    if (main === '') return {
      ok: false,
      error: `${label}: missing value`
    };
    const partsResult = splitParts(main);
    if (!partsResult.ok) return {
      ok: false,
      error: `${label}: ${partsResult.error}`
    };
    const parts = partsResult.parts;
    if (parts.length > 3) {
      return {
        ok: false,
        error: `${label}: too many comma-separated parts (max value, start, end)`
      };
    }
    const valueRaw = parts[0] || '';
    const startRaw = parts[1] || '';
    const endRaw = parts[2] || '';
    if (valueRaw === '') return {
      ok: false,
      error: `${label}: empty value`
    };
    if (valueRaw.startsWith('"') && (!valueRaw.endsWith('"') || valueRaw.length < 2)) {
      return {
        ok: false,
        error: `${label}: unterminated quoted string`
      };
    }
    let cStart = '';
    if (startRaw) {
      const c = canonicalizeDate(startRaw);
      if (c === null) return {
        ok: false,
        error: `${label}: invalid start date '${startRaw}'`
      };
      cStart = c;
    }
    let cEnd = '';
    if (endRaw) {
      const c = canonicalizeDate(endRaw);
      if (c === null) return {
        ok: false,
        error: `${label}: invalid end date '${endRaw}'`
      };
      cEnd = c;
    }
    return {
      ok: true,
      row: {
        value: normalizeValue(valueRaw),
        start: cStart,
        end: cEnd,
        id: id
      }
    };
  };
  const parsePattern = input => {
    const normalized = input.replace(RE_UNICODE_WS, ' ').trim();
    if (normalized === '') return {
      ok: true,
      rows: []
    };
    const segs = splitSegments(normalized);
    if (!segs.ok) return segs;
    const out = [];
    for (let i = 0; i < segs.segments.length; i++) {
      const r = parseSegment(segs.segments[i], `Segment ${i + 1}`);
      if (!r.ok) return r;
      out.push(r.row);
    }
    return {
      ok: true,
      rows: out
    };
  };
  const initialRows = [{
    value: '50000',
    start: '2024-01-01',
    end: '',
    id: ''
  }];
  const [rows, setRows] = useState(initialRows);
  const [patternInput, setPatternInput] = useState(() => computePattern(initialRows));
  const [error, setError] = useState(null);
  const [copied, setCopied] = useState(false);
  const syncFromRows = newRows => {
    setRows(newRows);
    setPatternInput(computePattern(newRows));
    setError(null);
  };
  const update = (i, field, v) => {
    const newRows = rows.map((row, j) => j === i ? {
      ...row,
      [field]: v
    } : row);
    syncFromRows(newRows);
  };
  const removeRow = i => {
    const newRows = rows.length > 1 ? rows.filter((_, j) => j !== i) : [emptyRow()];
    syncFromRows(newRows);
  };
  const addRow = () => syncFromRows([...rows, emptyRow()]);
  const onPatternChange = newValue => {
    setPatternInput(newValue);
    const r = parsePattern(newValue);
    if (r.ok) {
      setRows(r.rows.length > 0 ? r.rows : [emptyRow()]);
      setError(null);
    } else {
      setError(r.error);
    }
  };
  const finalizePattern = () => {
    const r = parsePattern(patternInput);
    if (!r.ok) {
      setError(r.error);
      return;
    }
    const newRows = r.rows.length > 0 ? r.rows : [emptyRow()];
    setRows(newRows);
    setPatternInput(computePattern(newRows));
    setError(null);
  };
  const onPatternBlur = () => finalizePattern();
  const onPatternPaste = () => setTimeout(finalizePattern, 0);
  const copy = () => {
    if (!patternInput) return;
    navigator.clipboard.writeText(patternInput).then(() => {
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    }).catch(() => undefined);
  };
  const addDays = (ymd, days) => {
    if (!RE_DATE_STRICT.test(ymd)) return null;
    const d = new Date(`${ymd}T00:00:00Z`);
    d.setUTCDate(d.getUTCDate() + days);
    return d.toISOString().slice(0, 10);
  };
  const entries = rows.filter(row => emitValue(row.value) !== '').flatMap(row => {
    const id = sanitizeId(row.id) || null;
    const start = row.start.trim() || null;
    const end = row.end.trim() || null;
    const displayValue = (() => {
      const v = emitValue(row.value);
      if (v.startsWith('"') && v.endsWith('"')) return v.slice(1, -1);
      return v;
    })();
    const out = [{
      valid_from: start,
      value: displayValue,
      id
    }];
    if (end) {
      out.push({
        valid_from: addDays(end, 1),
        value: null,
        id
      });
    }
    return out;
  }).sort((a, b) => {
    if (a.valid_from === b.valid_from) return 0;
    if (a.valid_from === null) return 1;
    if (b.valid_from === null) return -1;
    return a.valid_from < b.valid_from ? 1 : -1;
  });
  const css = `
        .dpp-builder {
          border: 1px solid rgb(var(--primary-light) / .25);
          border-radius: 10px;
          padding: 20px;
          margin: 24px 0;
          background: rgb(var(--primary-light) / .04);
          font-size: 14px;
        }
        .dpp-rows {
          display: flex;
          flex-direction: column;
          gap: 10px;
        }
        .dpp-row {
          display: grid;
          grid-template-columns: 2fr 1.3fr 1.3fr 1.3fr auto;
          gap: 8px;
          align-items: end;
        }
        .dpp-field {
          display: flex;
          flex-direction: column;
          gap: 4px;
          min-width: 0;
        }
        .dpp-label {
          font-size: 11px;
          font-weight: 600;
          text-transform: uppercase;
          letter-spacing: 0.05em;
          color: var(--colors-content-secondary, #6b7280);
        }
        .dpp-input {
          width: 100%;
          padding: 8px 10px;
          border: 1px solid rgb(var(--primary-light) / .35);
          border-radius: 6px;
          background: #ffffff;
          font-size: 13px;
          font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
          color: inherit;
          box-sizing: border-box;
        }
        :is(.dark, [data-theme="dark"]) .dpp-input {
          background: rgb(255 255 255 / .04);
          border-color: rgb(var(--primary-light) / .4);
        }
        .dpp-input:focus {
          outline: 2px solid rgb(var(--primary-light) / .5);
          outline-offset: -1px;
        }
        .dpp-btn {
          padding: 8px 12px;
          border: 1px solid rgb(var(--primary-light) / .4);
          border-radius: 6px;
          background: transparent;
          color: inherit;
          font-size: 13px;
          font-weight: 500;
          cursor: pointer;
          white-space: nowrap;
        }
        .dpp-btn:hover:not(:disabled) {
          background: rgb(var(--primary-light) / .1);
        }
        .dpp-btn:disabled {
          opacity: 0.4;
          cursor: not-allowed;
        }
        .dpp-btn-remove {
          padding: 8px 10px;
          color: var(--colors-content-secondary, #6b7280);
        }
        .dpp-actions {
          margin-top: 12px;
          display: flex;
          gap: 8px;
        }
        .dpp-divider {
          height: 1px;
          background: rgb(var(--primary-light) / .2);
          margin: 20px 0;
        }
        .dpp-section-label {
          font-size: 11px;
          font-weight: 600;
          text-transform: uppercase;
          letter-spacing: 0.05em;
          color: var(--colors-content-secondary, #6b7280);
          margin-bottom: 8px;
        }
        .dpp-pattern {
          display: flex;
          align-items: center;
          gap: 8px;
        }
        .dpp-pattern-input {
          flex: 1;
          padding: 10px 12px;
          background: rgb(var(--primary-light) / .1);
          border: 1px solid rgb(var(--primary-light) / .25);
          border-radius: 6px;
          font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
          font-size: 13px;
          color: inherit;
          box-sizing: border-box;
          min-width: 0;
          width: 100%;
        }
        .dpp-pattern-input:focus {
          outline: 2px solid rgb(var(--primary-light) / .5);
          outline-offset: -1px;
        }
        :is(.dark, [data-theme="dark"]) .dpp-pattern-input {
          background: rgb(var(--primary-light) / .15);
        }
        .dpp-pattern-error {
          margin-top: 8px;
          padding: 8px 12px;
          background: rgb(220 38 38 / .08);
          border: 1px solid rgb(220 38 38 / .3);
          border-radius: 6px;
          color: #b91c1c;
          font-size: 13px;
          font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
        }
        :is(.dark, [data-theme="dark"]) .dpp-pattern-error {
          color: #fca5a5;
          background: rgb(220 38 38 / .15);
        }
        .dpp-builder [data-table-wrapper] {
          margin: 0;
          padding: 0;
          width: 100%;
          overflow: visible;
          display: block;
        }
        .dpp-builder [data-table-wrapper] > div {
          padding: 0;
          display: block;
        }
        .dpp-builder [data-table-wrapper] table {
          margin: 0;
          min-width: 0;
        }
        .dpp-builder [data-table-wrapper] td {
          min-width: 0;
        }
        .dpp-entries-table {
          width: 100%;
          border-collapse: collapse;
          font-size: 13px;
          font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
        }
        .dpp-entries-table th {
          text-align: left;
          padding: 8px 10px;
          font-size: 11px;
          font-weight: 600;
          text-transform: uppercase;
          letter-spacing: 0.05em;
          color: var(--colors-content-secondary, #6b7280);
          border-bottom: 1px solid rgb(var(--primary-light) / .25);
          font-family: inherit;
        }
        .dpp-entries-table td {
          padding: 8px 10px;
          border-bottom: 1px solid rgb(var(--primary-light) / .15);
        }
        .dpp-entries-table th:first-child,
        .dpp-entries-table td:first-child {
          padding-left: 0;
        }
        .dpp-entries-table tr:last-child td {
          border-bottom: none;
        }
        .dpp-null {
          color: var(--colors-content-secondary, #6b7280);
        }
        .dpp-terminator {
          color: var(--colors-warning, #b45309);
        }
        :is(.dark, [data-theme="dark"]) .dpp-terminator {
          color: #fbbf24;
        }
        .dpp-empty-hint {
          color: var(--colors-content-secondary, #6b7280);
          font-style: italic;
          font-size: 13px;
        }
        @media (max-width: 720px) {
          .dpp-row {
            grid-template-columns: 1fr 1fr;
          }
          .dpp-row > button {
            grid-column: 1 / -1;
            justify-self: end;
          }
        }
      `;
  return <div className="dpp-builder">
      <style>{css}</style>
      <div className="dpp-rows">
        {rows.map((row, i) => <div key={i} className="dpp-row">
            <div className="dpp-field">
              <span className="dpp-label">Value</span>
              <input className="dpp-input" type="text" value={row.value} onChange={e => update(i, 'value', e.target.value)} placeholder="e.g. 50000" />
            </div>
            <div className="dpp-field">
              <span className="dpp-label">Start date</span>
              <input className="dpp-input" type="date" value={row.start} onChange={e => update(i, 'start', e.target.value)} />
            </div>
            <div className="dpp-field">
              <span className="dpp-label">End date</span>
              <input className="dpp-input" type="date" value={row.end} onChange={e => update(i, 'end', e.target.value)} />
            </div>
            <div className="dpp-field">
              <span className="dpp-label">ID</span>
              <input className="dpp-input" type="text" value={row.id} onChange={e => update(i, 'id', e.target.value)} placeholder="optional" />
            </div>
            <button className="dpp-btn dpp-btn-remove" onClick={() => removeRow(i)} disabled={rows.length === 1} aria-label="Remove value">
              Remove
            </button>
          </div>)}
      </div>
      <div className="dpp-actions">
        <button className="dpp-btn" onClick={addRow}>
          Add value
        </button>
      </div>

      <div className="dpp-divider" />

      <div className="dpp-section-label">Pattern</div>
      <div className="dpp-pattern">
        <input type="text" className="dpp-pattern-input" value={patternInput} onChange={e => onPatternChange(e.target.value)} onBlur={onPatternBlur} onPaste={onPatternPaste} placeholder="Type or paste a pattern..." />
        <button className="dpp-btn" onClick={copy} disabled={!patternInput}>
          {copied ? 'Copied' : 'Copy'}
        </button>
      </div>
      {error ? <div className="dpp-pattern-error">{error}</div> : null}

      <div className="dpp-divider" />

      <div className="dpp-section-label">Will produce these dated property entries</div>
      {entries.length === 0 ? <div className="dpp-empty-hint">Nothing yet.</div> : <table className="dpp-entries-table">
          <thead>
            <tr>
              <th>valid_from</th>
              <th>value</th>
              <th>id</th>
            </tr>
          </thead>
          <tbody>
            {entries.map((e, i) => <tr key={i}>
                <td>
                  {e.valid_from || <span className="dpp-null">null</span>}
                </td>
                <td>
                  {e.value === null ? <span className="dpp-terminator">null (terminator)</span> : e.value}
                </td>
                <td>{e.id || <span className="dpp-null">null</span>}</td>
              </tr>)}
          </tbody>
        </table>}
    </div>;
};

Some source systems don't have first-class support for date-tracked values. They expose a single text field per property, but the business meaning is "this value applies from 2024-01-01" or "these two values apply to different time ranges". The dated property pattern is a compact string format for packing that information into a single text field, which Twine then expands into a proper list of [dated property](/platform/data-model#dated-properties) entries.

The pattern is consumed by a **pattern converter** step on a property mapping: the raw source text is read, the converter parses it, and the resulting entries flow into the entity like any other dated property.

## Builder

Fill in the fields below to compose a valid pattern. The generated string is shown underneath, together with the dated property entries it will produce.

<DatedPropertyPatternBuilder />

## A value on its own

The simplest pattern is just a value:

```
50000
```

This becomes a single dated property entry with `valid_from: null` - meaning the value has been in effect since the beginning of time. This is appropriate for properties where history is not meaningful.

## A value with a start date

Separate the value from its effective date with a comma:

```
50000,2024-01-01
```

The value takes effect on the given date and stays in effect indefinitely. Whitespace around the comma is optional: `50000, 2024-01-01` parses identically.

## A value within a closed interval

Add an end date to describe a value that was only valid for a bounded period:

```
50000,2024-01-01,2024-12-31
```

A closed interval always produces **two** dated property entries: the value itself, and a **terminator** on the day after the end date. The terminator is an entry with `value: null` that signals the property is no longer active. This mirrors how closed intervals are represented throughout Twine.

| valid\_from  | value   | meaning                               |
| ------------ | ------- | ------------------------------------- |
| `2024-01-01` | `50000` | value takes effect                    |
| `2025-01-01` | `null`  | value is cleared from this day onward |

<Warning>
  Querying the property at `end_date + 1` will return the terminator, not the value. This is almost always what you want - but it surprises people the first time they see it.
</Warning>

If you only need an end date but no start, use an empty start slot with two commas:

```
50000,,2024-12-31
```

This produces the value with `valid_from: null` plus a terminator on 2025-01-01.

## Multiple values in a single pattern

Separate multiple values with `/` to describe a full history in one string:

```
45000,2022-06-01 / 50000,2024-01-01
```

Each segment is parsed independently. The order in the source text does not matter - Twine sorts the resulting entries by `valid_from` in descending order (newest first) as part of its standard dated property handling. Whitespace around `/` is optional.

## Grouping values with an ID

Some properties can hold several independent values at the same time - see [properties with multiple simultaneous values](/platform/data-model#properties-with-multiple-simultaneous-values). Attach an ID to each segment with `#` to keep those series apart:

```
premium,2023-01-01#benefit_001 / standard,2023-01-01#benefit_002
```

An ID must not contain whitespace, `/`, `,`, or `#`. A single optional space is allowed between the date and the `#`:

```
premium,2023-01-01 #benefit_001
```

When an ID is attached to a closed interval, both the value entry and the terminator carry the same ID.

## Value types

The parser recognises the following value forms and converts them to typed values. The first rule that matches is used.

| Form                     | Example              | Parsed as                             |
| ------------------------ | -------------------- | ------------------------------------- |
| Integer                  | `50000`, `-1`        | integer                               |
| Decimal                  | `2.5`                | float                                 |
| Percentage               | `40%`, `45.5%`       | float divided by 100 (`0.4`, `0.455`) |
| Hours                    | `4h`, `7.25H`        | float (hours as a decimal)            |
| Hours and minutes        | `4:30`               | float (`4.5`)                         |
| Date (in value position) | `2020-01-01`         | date                                  |
| Quoted string            | `"hej baberiba"`     | string (quotes stripped)              |
| Any other text           | `pending`, `grade_a` | string                                |

Raw text values cannot contain `,` or `/`, since those are structural delimiters. Use a quoted string if the value needs to contain either of them, or leading or trailing whitespace:

```
"cost_center/eu",2024-01-01
```

Quoted strings have no escape syntax. The first `"` after the opening quote ends the string.

<Note>
  Dates in **value position** use strict `YYYY-MM-DD` form with literal dash separators. Dates in **start or end position** accept a few additional forms - see [Date formats](#date-formats) below.
</Note>

## Decimal numbers and the comma separator

Comma is used both as a part delimiter and, in European conventions, as a decimal separator. The parser handles both, but the rule is subtle enough to be worth stating explicitly.

A comma decimal is only recognised when the number is followed by another comma or the end of input. In every other position, the comma is treated as a delimiter.

| Pattern          | Result                                   |
| ---------------- | ---------------------------------------- |
| `2,5`            | float `2.5`                              |
| `2,5,2024-01-01` | float `2.5` with start date `2024-01-01` |
| `45,5%`          | float `0.455`                            |

If you are unsure, use `.` as the decimal separator - it is unambiguous everywhere. The builder above always emits dots.

## Date formats

In **start and end position**, dates accept three forms. All three validate the calendar: invalid combinations such as `2020-02-30` cause the whole pattern to fail.

| Form                       | Example                  | Notes                                       |
| -------------------------- | ------------------------ | ------------------------------------------- |
| Dashed, four-digit year    | `2020-01-02`             | The canonical form                          |
| Space-separated or compact | `2020 01 02`, `20240831` | Equivalent to the dashed form               |
| Two-digit year             | `20-01-02`               | Interpreted as `2000 + yy`, so `2020-01-02` |

In **value position**, only the strict dashed form with a four-digit year is accepted, to keep date values unambiguous when they share a pattern with numeric values.

## Whitespace

Whitespace around `/` and `,` is ignored. Inside a value or an ID, whitespace is significant and must be wrapped in quotes if the grammar would otherwise stop at it.

The parser normalises these Unicode spaces to a regular ASCII space before parsing: U+00A0 (non-breaking space), U+2007 (figure space), U+2009 (thin space), U+202F (narrow no-break space), U+200B (zero-width space). This is useful when copy-pasting from sources that inject invisible spaces. Tabs and other Unicode whitespace are not normalised.

The whole pattern is trimmed before parsing.

## What is not supported

* Tabs as whitespace, and Unicode whitespace beyond the normalised set above.
* Escape sequences inside quoted strings.
* Raw text values containing `,` or `/` - use a quoted string.
* Negative percentages and negative hours.
* Times of day (`HH:MM:SS`). `HH:MM` is accepted, but only as a duration.
* Datetimes.
* An end date without a start date in any form other than `value,,end_date`.

## Errors

A pattern either parses into a list of dated property entries or returns an error. Errors are always returned as values - the converter never raises.

Two kinds of errors can occur:

* **Unparsed input** - the parser consumed part of the input and then got stuck at a character it did not expect. The error points to the column where parsing halted.
* **Invalid content** - the grammar was satisfied but the content does not make sense, for example an invalid calendar date or a malformed number.

An empty or missing pattern is not an error: it produces an empty list of entries.
