Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions changes/vbp6-datetime-custom-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
type: added
facade: breaking
---
[en]
A plugin can now declare a custom field of type `datetime` (`[fields.<name>] type = "datetime"`) for a moment such as a meeting's start. A value must be an RFC 3339 timestamp with a time zone (`Z` or an offset such as `+02:00`); a plain date or a time without a zone is refused. The value is stored as the same moment in UTC at whole seconds: `--set occurred_on=2026-10-12T10:30:00+02:00` stores `2026-10-12T08:30:00Z`, and fractional seconds are dropped. Because every value has that one shape, `datetime` values sort in time order wherever a field is read, such as a ranking key's `of = { field = "..." }`, locally and on a hosted board alike. The offset that was typed is not kept. `nxf schema --json` and MCP `flow_schema` report the field with `"kind": "datetime"`; `create`/`update --set` and MCP `flow_create`/`flow_update` validate it. Breaking for embedders that match on `plugin::FieldType`: it gains the variant `Datetime` and is now `#[non_exhaustive]`, so a match needs a wildcard arm; later field types will then be additive.
[de]
Ein Plugin kann jetzt ein eigenes Feld vom Typ `datetime` deklarieren (`[fields.<name>] type = "datetime"`), für einen Zeitpunkt wie den Beginn eines Meetings. Ein Wert muss ein RFC-3339-Zeitstempel mit Zeitzone sein (`Z` oder ein Offset wie `+02:00`); ein reines Datum oder eine Uhrzeit ohne Zone wird abgelehnt. Gespeichert wird derselbe Zeitpunkt in UTC auf ganze Sekunden: `--set occurred_on=2026-10-12T10:30:00+02:00` speichert `2026-10-12T08:30:00Z`, Sekundenbruchteile entfallen. Weil jeder Wert diese eine Form hat, sortieren `datetime`-Werte überall zeitlich, wo ein Feld gelesen wird, etwa im `of = { field = "..." }` eines Ranking-Schlüssels, lokal wie auf einem gehosteten Brett. Der eingegebene Offset bleibt nicht erhalten. `nxf schema --json` und MCP `flow_schema` melden das Feld mit `"kind": "datetime"`; `create`/`update --set` und MCP `flow_create`/`flow_update` prüfen es. Brechend für Einbettende, die auf `plugin::FieldType` matchen: Es bekommt die Variante `Datetime` und ist jetzt `#[non_exhaustive]`, ein Match braucht also einen Wildcard-Arm; spätere Feldtypen sind dann additiv.
30 changes: 30 additions & 0 deletions crates/cli/docs/guide/de/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,36 @@ das Plugin ist, wie ein bestimmtes Publikum sie liest und rankt. Ein Plugin bei
ist daher eine echte Entscheidung; führen Sie `nxf init --help` aus, um die Optionen zu sehen,
bevor Sie sich festlegen.

## Eigene Felder deklarieren

Ein Plugin kann seinen Typen unter `[fields.<name>]` eigene Felder geben. `nxf schema --json`
listet sie mit `"custom": true` und ihrer `kind`; `create` und `update` setzen sie mit
`--set <name>=<wert>`; `show --json` und die Listen geben die gesetzten unter `custom` zurück. Ein
leerer Wert löscht ein Feld.

```toml
[fields.stage]
type = "enum"
values = ["backlog", "active", "done"]
on = ["project"] # die Typen, für die es gilt; weglassen heißt: für jeden Typ

[fields.occurred_on]
type = "datetime"
label = "Occurred on"
```

Der `type` entscheidet, was ein Wert sein darf:

- `text` und `longtext`: beliebiger Text.
- `date`: ein Datum (`2026-10-12`) oder ein RFC-3339-Zeitstempel, gespeichert wie angegeben.
- `datetime`: ein Zeitpunkt, als RFC-3339-Zeitstempel **mit Zeitzone** — `Z` oder ein Offset wie
`+02:00`. Gespeichert wird derselbe Zeitpunkt in UTC auf ganze Sekunden:
`--set occurred_on=2026-10-12T10:30:00+02:00` speichert `2026-10-12T08:30:00Z`. Ein reines Datum
oder eine Uhrzeit ohne Zone wird abgelehnt. Weil jeder Wert dieselbe Form hat, sortieren
`datetime`-Werte überall zeitlich, wo ein Feld gelesen wird, etwa im `of = { field = "..." }`
eines Ranking-Schlüssels. Der eingegebene Offset bleibt nicht erhalten.
- `enum`: einer der deklarierten `values`.

## Ein Ranking deklarieren

Ein Plugin deklariert unter `[ranking.next]`, wie `next` rankt, und unter `[ranking.list]`, wie
Expand Down
30 changes: 30 additions & 0 deletions crates/cli/docs/guide/en/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,36 @@ That doubling is not redundancy — it *is* the explanation. The `--json` is the
plugin is how a particular audience reads and ranks it. Picking a plugin at `init` is therefore
a real choice; run `nxf init --help` to see the options before you commit.

## Declaring custom fields

A plugin can give its types fields of their own under `[fields.<name>]`. `nxf schema --json` lists
them with `"custom": true` and their `kind`; `create` and `update` set them with
`--set <name>=<value>`; `show --json` and the lists return the set ones under `custom`. An empty
value clears a field.

```toml
[fields.stage]
type = "enum"
values = ["backlog", "active", "done"]
on = ["project"] # the types it applies to; omit it for every type

[fields.occurred_on]
type = "datetime"
label = "Occurred on"
```

The `type` decides what a value may be:

- `text` and `longtext`: any text.
- `date`: a date (`2026-10-12`) or an RFC 3339 timestamp, stored as given.
- `datetime`: a moment, as an RFC 3339 timestamp **with a time zone** — `Z` or an offset such as
`+02:00`. It is stored as the same moment in UTC at whole seconds:
`--set occurred_on=2026-10-12T10:30:00+02:00` stores `2026-10-12T08:30:00Z`. A plain date or a
time without a zone is refused. Because every value has the same shape, `datetime` values sort
in time order wherever a field is read, such as a ranking key's `of = { field = "..." }`. The
offset you typed is not kept.
- `enum`: one of the declared `values`.

## Declaring a ranking

A plugin declares how `next` ranks under `[ranking.next]` and how `list --sort rank` ranks under
Expand Down
45 changes: 43 additions & 2 deletions crates/facade/src/plugin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,19 @@ pub enum MergeStrategy {
/// The declared data type of a plugin custom field (plugin-custom-fields §3, TB-CF-3). Governs
/// value validation and presentation, never storage (a value is always a `TEXT` LWW cell). The v1
/// set: `text`/`longtext` are free UTF-8; `date` is ISO-8601 (the same check `due`/`defer` use);
/// `enum` is one of a declared `values` list. Richer types (`int`/`bool`/format-validated
/// uri/email/color) are additive follow-ups (§9).
/// `datetime` is an RFC 3339 instant with a time zone, stored in UTC at whole seconds (nxf
/// 6j6v.vbp6, [`validate::datetime`](crate::validate::datetime)); `enum` is one of a declared
/// `values` list. Richer types (`int`/`bool`/format-validated uri/email/color) are additive
/// follow-ups (§9). `#[non_exhaustive]` since 6j6v.vbp6, so the next type is an additive change for
/// a consumer that matches on it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum FieldType {
Text,
Longtext,
Date,
Datetime,
Enum,
}

Expand All @@ -91,6 +96,7 @@ impl FieldType {
FieldType::Text => "text",
FieldType::Longtext => "longtext",
FieldType::Date => "date",
FieldType::Datetime => "datetime",
FieldType::Enum => "enum",
}
}
Expand Down Expand Up @@ -1350,6 +1356,41 @@ mod tests {
assert_eq!(stage.merge, MergeStrategy::CrdtText);
}

#[test]
fn a_datetime_field_parses_validates_and_reports_its_kind() {
// vbp6: `type = "datetime"` is a declarable type, spelled `datetime` on the wire, and like
// every non-enum it refuses `values`.
let cfg = cfg_with_fields(
r#"
[fields.occurred_on]
type = "datetime"
on = ["project"]
label = "Occurred on"
"#,
);
assert!(cfg.validate_fields().is_ok());
let decl = &cfg.fields["occurred_on"];
assert_eq!(decl.field_type, FieldType::Datetime);
assert_eq!(decl.field_type.as_str(), "datetime");
assert!(
cfg_with_fields("[fields.at]\ntype = \"datetime\"\nvalues = [\"a\"]")
.validate_fields()
.is_err(),
"a datetime field carries no enum values"
);
// The spelling is exact: `date-time`/`dateTime` are not types.
for bad in ["date-time", "dateTime", "timestamp"] {
let toml = format!("[fields.at]\ntype = \"{bad}\"");
let parsed = toml::from_str::<toml::Table>(&toml)
.unwrap()
.get("fields")
.cloned()
.unwrap()
.try_into::<BTreeMap<String, FieldDecl>>();
assert!(parsed.is_err(), "'{bad}' is not a field type");
}
}

#[test]
fn validate_fields_rejects_a_canonical_name_collision() {
// §3 / TB-CF-6: a custom field must not shadow a canonical field or alias — a loud load
Expand Down
2 changes: 2 additions & 0 deletions crates/facade/src/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@ fn titleise(name: &str) -> String {
pub struct SchemaField {
pub field: String,
pub label: String,
/// How the value is interpreted: a canonical field's kind, or a custom field's declared type
/// (`text | longtext | date | datetime | enum`, [`FieldType::as_str`](crate::plugin::FieldType::as_str)).
pub kind: String,
pub required: bool,
pub settable: bool,
Expand Down
95 changes: 95 additions & 0 deletions crates/facade/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,36 @@ pub fn iso_date(value: &str) -> Result<String> {
}
}

/// Validate a `datetime` custom-field value (nxf 6j6v.vbp6) and return its stored form. The input
/// must be an RFC 3339 timestamp WITH a time zone (`Z` or `±hh:mm`); a plain date or a local time
/// without an offset is refused. The stored form is the same instant in UTC at whole seconds,
/// `YYYY-MM-DDTHH:MM:SSZ`: fractional seconds are dropped and the given offset is applied, then
/// discarded. One fixed shape means the text order of stored values IS their chronological order,
/// which is what every comparison downstream relies on (the SQL sort, the hosted fold, the ranking
/// comparator's text arm). A UTC year outside `0000..=9999` is refused for the same reason.
pub fn datetime(value: &str) -> Result<String> {
let refuse = || {
NxfError::validation(format!(
"'{value}' is not an RFC 3339 date-time with a time zone \
(e.g. 2026-10-12T08:30:00Z or 2026-10-12T10:30:00+02:00)"
))
};
let utc = OffsetDateTime::parse(value, &Rfc3339)
.map_err(|_| refuse())?
.checked_to_offset(time::UtcOffset::UTC)
.filter(|utc| (0..=9999).contains(&utc.year()))
.ok_or_else(refuse)?;
Ok(format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
utc.year(),
u8::from(utc.month()),
utc.day(),
utc.hour(),
utc.minute(),
utc.second()
))
}

/// Canonicalize + validate a user label (h89s): trim surrounding whitespace, reject an empty
/// result, and reject the U+001F composite separator (it would break the OR-set's `item␟label`
/// target_id). Returns the stored form. The ONE definition every label path shares — the facade
Expand Down Expand Up @@ -166,4 +196,69 @@ mod tests {
assert!(iso_date("2026-13-01").is_err());
assert!(iso_date("2026/06/09").is_err());
}

#[test]
fn datetime_normalizes_any_offset_to_utc_seconds() {
// The offset is applied, not dropped: 10:30 at +02:00 is 08:30 UTC.
assert_eq!(
datetime("2026-10-12T10:30:00+02:00").unwrap(),
"2026-10-12T08:30:00Z"
);
assert_eq!(
datetime("2026-10-12T08:30:00Z").unwrap(),
"2026-10-12T08:30:00Z"
);
// A negative offset can cross midnight into the next day.
assert_eq!(
datetime("2026-12-31T23:00:00-05:00").unwrap(),
"2027-01-01T04:00:00Z"
);
// Lowercase separators are RFC 3339 too.
assert_eq!(
datetime("2026-10-12t08:30:00z").unwrap(),
"2026-10-12T08:30:00Z"
);
}

#[test]
fn datetime_drops_fractional_seconds() {
// Fixed precision keeps text order chronological: `…00.5Z` would sort BEFORE `…00Z`.
assert_eq!(
datetime("2026-10-12T08:30:00.000Z").unwrap(),
"2026-10-12T08:30:00Z"
);
assert_eq!(
datetime("2026-10-12T08:30:59.999999+00:00").unwrap(),
"2026-10-12T08:30:59Z"
);
}

#[test]
fn datetime_refuses_a_value_without_a_time_zone() {
for v in [
"2026-10-12",
"2026-10-12T08:30:00",
"2026-10-12T08:30",
"2026-10-12 08:30:00",
"not-a-date",
"2026-13-01T00:00:00Z",
"",
] {
let err = datetime(v).expect_err(v);
assert_eq!(err.kind, crate::error::ErrorKind::Validation, "{v}");
assert!(err.msg.contains("RFC 3339"), "{v}: {}", err.msg);
}
}

#[test]
fn datetime_refuses_a_utc_year_outside_four_digits() {
// In range as given, out of range once moved to UTC: refused, not stored with a 5-digit or
// negative year that would break the text order.
assert!(datetime("9999-12-31T23:30:00-01:00").is_err());
assert!(datetime("0000-01-01T00:30:00+01:00").is_err());
assert_eq!(
datetime("0000-01-01T00:30:00Z").unwrap(),
"0000-01-01T00:30:00Z"
);
}
}
18 changes: 11 additions & 7 deletions crates/facade/src/write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -528,23 +528,27 @@ fn prepare_custom_set(
)));
}
// Per-type value validation — skipped for an empty value, which is the clear sentinel (§4.2).
if !value.is_empty() {
// A `datetime` value is the one type stored in a canonical form rather than as given (vbp6).
let canonical = if value.is_empty() {
String::new()
} else {
match decl.field_type {
FieldType::Date => {
validate::iso_date(value)?;
}
FieldType::Date => validate::iso_date(value)?,
FieldType::Datetime => validate::datetime(value)
.map_err(|e| NxfError::validation(format!("custom field '{name}': {}", e.msg)))?,
FieldType::Enum => {
if !decl.values.iter().any(|v| v == value) {
return Err(NxfError::validation(format!(
"'{value}' is not a valid '{name}' value; expected one of {:?}",
decl.values
)));
}
value.to_string()
}
FieldType::Text | FieldType::Longtext => {}
FieldType::Text | FieldType::Longtext => value.to_string(),
}
}
Ok((value.to_string(), custom_merge_strategy(decl.merge)))
};
Ok((canonical, custom_merge_strategy(decl.merge)))
}

/// Parse + validate the create/update custom-field `--set name=value` pairs (plugin-custom-fields
Expand Down
34 changes: 34 additions & 0 deletions crates/facade/tests/derived_ranking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,8 @@ fn kinds_cfg(order: &str) -> PluginConfig {
columns = ["id"]
[fields.stage]
type = "text"
[fields.met]
type = "datetime"
"#
))
.unwrap()
Expand Down Expand Up @@ -875,6 +877,38 @@ fn related_honours_its_type_filter_a_precedence_and_a_custom_field() {
);
}

#[test]
fn related_reads_a_datetime_custom_field_in_chronological_order() {
// vbp6: a `datetime` value is stored as the UTC instant, so `pick` and `dir` compare instants,
// not the text as typed. As typed, b1's "09:00Z" sorts before n1's "10:00+02:00"; as instants
// n1 (08:00Z) is earlier.
let mut s = kinds_board();
let cfg = kinds_cfg(r#"[ { related = "mentions", of = { field = "met" } } ]"#);
let set = |s: &mut Store, id: &str, v: &str| {
nexus_flow_facade::write::update(s, &cfg, NOW, "t", id, &[format!("met={v}")]).unwrap();
};
set(&mut s, "zz12.n1", "2026-10-12T10:00:00+02:00");
set(&mut s, "zz12.b1", "2026-10-12T09:00:00Z");
set(&mut s, "zz12.b2", "2026-10-12T07:59:59.900-00:00");
edge(&mut s, "zz12.i1", "zz12.b1", EdgeKind::Mentions); // 09:00Z
edge(&mut s, "zz12.i2", "zz12.n1", EdgeKind::Mentions); // 08:00Z
edge(&mut s, "zz12.i3", "zz12.b1", EdgeKind::Mentions); // 09:00Z and
edge(&mut s, "zz12.i3", "zz12.b2", EdgeKind::Mentions); // 07:59:59Z
assert_eq!(
ranked_items(&cfg, &s),
["zz12.i3", "zz12.i2", "zz12.i1", "zz12.i4"],
"min, ascending: the earliest instant first; no neighbour → null, last"
);
let latest = kinds_cfg(
r#"[ { related = "mentions", of = { field = "met" }, pick = "max", dir = "desc" } ]"#,
);
assert_eq!(
ranked_items(&latest, &s),
["zz12.i1", "zz12.i3", "zz12.i2", "zz12.i4"],
"max, descending: i1 and i3 both read 09:00Z and fall to the id"
);
}

#[test]
fn related_reads_a_neighbours_rank_in_its_own_order() {
let mut s = kinds_board();
Expand Down
Loading
Loading