From 2169a86f0333571e26c5bb47e44f42b35868f15d Mon Sep 17 00:00:00 2001 From: Cory Donnelly <152584+crd@users.noreply.github.com> Date: Thu, 14 May 2026 05:53:11 -0400 Subject: [PATCH] fix: honor documented repeater cookie semantics (#12523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: honor the three documented repeater cookie semantics Logseq's documented repeater semantics (per docs.logseq.com and `logseq/docs` `Tasks.md`) define three org-mode-style cookies for recurring tasks: `.+`: repeats from the last completion date `++`: advances from scheduled, skipping in whole intervals to future `+`: advances from scheduled by the declared interval (can stack) The scheduler in `worker/commands.cljs` has been ignoring the cookie entirely and applying a single, `++`-like semantic for every recurring task. A user who wrote `.+1w` in markdown — expecting "a week from when I actually finished" — silently got `++1w` behavior ("a week from the original scheduled date, skipped to future"), which for a weekly task scheduled 2026-04-01 and completed on 2026-04-05 returns the next occurrence on 2026-04-08 rather than the documented 2026-04-12. This change: * Adds a closed-values `:logseq.property.repeat/repeat-type` property with values `:dotted-plus` / `:plus` / `:double-plus`. Default is `:double-plus` so existing recurring tasks see no behavior change on upgrade. * Rewrites the scheduler to branch on repeat-type and implement each semantic: `.+` anchors on now; `+` advances from original once (can stack overdue, per org-mode); `++` iterates in whole intervals until strictly after now. The `++` path is mathematically equivalent to the previous scheduler, so default-path behavior is preserved. * Guards against frequency <= 0 — the old code would silently produce nonsense and, under the new `++` loop, would spin forever. The guard short-circuits to `nil`. * Extracts `resolve-recur-frequency` and fixes the previous `(or [A B] [C D])` pattern in `compute-reschedule-property-tx` — any 2-vector is truthy in Clojure, so the default-value branch was unreachable and entities without an explicit `:recur-frequency` silently fell through to `frequency = nil`. `if-let` makes both branches reachable so default-to-1 actually works at migration time. * Restores the cookie-type selector that was removed from the date-time popover in `0a5b88467` (Nov 2020) — in-code support for all three cookies has been present but not user-pickable for the last ~5.5 years. * Adds `docs/recurring-tasks.md` — a technical spec for contributors and users that restates and augments the upstream `Tasks.md` text, adds decision guidance, and documents the implementation surface. * Extends the file-graph → DB-graph migration (built on top of `44d6bd49c4` "fix: preserve repeated schedule import") to also carry the cookie kind via a new `repeat-types` map in `graph-parser/exporter.cljs`, so an imported `.+1w` task lands in the DB-graph with `:repeat-type.dotted-plus` rather than picking up the `:double-plus` default. Test updated to assert this. * Adds deftests covering each cookie's distinctive behavior plus boundary cases (non-positive frequency, unknown unit, frequency > 1 variants, `++` at month/year units, and both branches of `resolve-recur-frequency`). The preexisting `get-next-time-test` passes unchanged under the `:double-plus` default, preserving the existing regression contract. Tests pin `t/now` via `with-redefs` for determinism. Refs #7731, #11260, #6715, #8531. Folds in the small remaining delta from #12532 (now closed as superseded by `44d6bd49c4`). * fix: harden recurring task repeat type * fix: contain repeat type selector Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: handle clamped monthly repeats --------- Co-authored-by: Tienson Qin Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- deps/db/src/logseq/db/frontend/property.cljs | 13 + deps/db/src/logseq/db/frontend/schema.cljs | 2 +- .../src/logseq/graph_parser/exporter.cljs | 15 +- .../logseq/graph_parser/exporter_test.cljs | 8 +- docs/recurring-tasks.md | 129 ++++++ .../frontend/components/property/value.cljs | 4 + .../frontend/components/property/value.css | 14 + src/main/frontend/worker/commands.cljs | 110 +++-- src/main/frontend/worker/db/migrate.cljs | 3 +- src/resources/dicts/en.edn | 6 + src/resources/dicts/zh-cn.edn | 6 + src/test/frontend/worker/commands_test.cljs | 390 ++++++++++++++---- src/test/frontend/worker/migrate_test.cljs | 18 + 13 files changed, 588 insertions(+), 130 deletions(-) create mode 100644 docs/recurring-tasks.md diff --git a/deps/db/src/logseq/db/frontend/property.cljs b/deps/db/src/logseq/db/frontend/property.cljs index 3d7eb3de8d..dcba0d0684 100644 --- a/deps/db/src/logseq/db/frontend/property.cljs +++ b/deps/db/src/logseq/db/frontend/property.cljs @@ -373,6 +373,19 @@ :schema {:type :checkbox :hide? true} :queryable? true} + :logseq.property.repeat/repeat-type {:title "Repeating type" + :schema {:type :default + :public? false} + :closed-values (mapv (fn [[db-ident value]] + {:db-ident db-ident + :value value + :uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident)}) + [[:logseq.property.repeat/repeat-type.dotted-plus "Advance from completion"] + [:logseq.property.repeat/repeat-type.plus "Advance from scheduled"] + [:logseq.property.repeat/repeat-type.double-plus "Advance from scheduled, skip to future"]]) + :properties {:logseq.property/hide-empty-value true + :logseq.property/default-value :logseq.property.repeat/repeat-type.double-plus} + :queryable? true} :logseq.property.repeat/temporal-property {:title "Repeating Temporal Property" :schema {:type :property :hide? true}} diff --git a/deps/db/src/logseq/db/frontend/schema.cljs b/deps/db/src/logseq/db/frontend/schema.cljs index fc4b6baa6e..a5421c67e6 100644 --- a/deps/db/src/logseq/db/frontend/schema.cljs +++ b/deps/db/src/logseq/db/frontend/schema.cljs @@ -30,7 +30,7 @@ (map (juxt :major :minor) [(parse-schema-version x) (parse-schema-version y)]))) -(def version (parse-schema-version "65.25")) +(def version (parse-schema-version "65.26")) (defn major-version "Return a number. diff --git a/deps/graph-parser/src/logseq/graph_parser/exporter.cljs b/deps/graph-parser/src/logseq/graph_parser/exporter.cljs index ba722541c1..ed16ebd47d 100644 --- a/deps/graph-parser/src/logseq/graph_parser/exporter.cljs +++ b/deps/graph-parser/src/logseq/graph_parser/exporter.cljs @@ -526,13 +526,24 @@ "Month" :logseq.property.repeat/recur-unit.month "Year" :logseq.property.repeat/recur-unit.year}) +(def ^:private repeat-types + "Maps the mldoc repetition kind to the corresponding `:repeat-type` + closed-value db-ident. `:double-plus` matches the scheduler's fallback for + unknown values." + {"Dotted" :logseq.property.repeat/repeat-type.dotted-plus + "Plus" :logseq.property.repeat/repeat-type.plus + "DoublePlus" :logseq.property.repeat/repeat-type.double-plus}) + (defn- repeat-properties [temporal-property value] - (when-let [[_ unit frequency] (and (map? value) (:repetition value))] - (let [unit-ident (get repeat-recur-units (first unit))] + (when-let [[kind unit frequency] (and (map? value) (:repetition value))] + (let [unit-ident (get repeat-recur-units (first unit)) + repeat-type-ident (get repeat-types (first kind) + :logseq.property.repeat/repeat-type.double-plus)] (assert unit-ident (str "Unknown repeat unit: " (pr-str unit))) {:logseq.property.repeat/repeated? true :logseq.property.repeat/temporal-property temporal-property + :logseq.property.repeat/repeat-type repeat-type-ident :logseq.property.repeat/recur-frequency frequency :logseq.property.repeat/recur-unit unit-ident}))) diff --git a/deps/graph-parser/test/logseq/graph_parser/exporter_test.cljs b/deps/graph-parser/test/logseq/graph_parser/exporter_test.cljs index abcc95ec85..ab793837e7 100644 --- a/deps/graph-parser/test/logseq/graph_parser/exporter_test.cljs +++ b/deps/graph-parser/test/logseq/graph_parser/exporter_test.cljs @@ -281,17 +281,20 @@ "Repeated scheduled timestamp keeps its time") (is (= {:logseq.property.repeat/repeated? true :logseq.property.repeat/temporal-property :logseq.property/scheduled + :logseq.property.repeat/repeat-type :logseq.property.repeat/repeat-type.dotted-plus :logseq.property.repeat/recur-frequency 1 :logseq.property.repeat/recur-unit :logseq.property.repeat/recur-unit.year} (select-keys birthday-properties [:logseq.property.repeat/repeated? :logseq.property.repeat/temporal-property + :logseq.property.repeat/repeat-type :logseq.property.repeat/recur-frequency :logseq.property.repeat/recur-unit])) - "Repeated scheduled timestamp keeps its repeat properties") + "Repeated scheduled timestamp keeps its repeat properties including the `.+` cookie kind") (is (= {:logseq.property/deadline 20251107 :logseq.property.repeat/repeated? true :logseq.property.repeat/temporal-property :logseq.property/deadline + :logseq.property.repeat/repeat-type :logseq.property.repeat/repeat-type.plus :logseq.property.repeat/recur-frequency 2 :logseq.property.repeat/recur-unit :logseq.property.repeat/recur-unit.week} (-> report-properties @@ -299,9 +302,10 @@ (select-keys [:logseq.property/deadline :logseq.property.repeat/repeated? :logseq.property.repeat/temporal-property + :logseq.property.repeat/repeat-type :logseq.property.repeat/recur-frequency :logseq.property.repeat/recur-unit]))) - "Repeated deadline timestamp keeps its repeat properties") + "Repeated deadline timestamp keeps its repeat properties including the `+` cookie kind") (is (empty? (map :entity (:errors (db-validate/validate-local-db! @conn)))) "Imported graph validates")))) diff --git a/docs/recurring-tasks.md b/docs/recurring-tasks.md new file mode 100644 index 0000000000..d6a6ca4912 --- /dev/null +++ b/docs/recurring-tasks.md @@ -0,0 +1,129 @@ +# Recurring Tasks + +Logseq supports recurring tasks with three scheduling semantics, modeled +on org-mode's three repeater cookies. Picking the right one lets you +say exactly how the next occurrence should be computed when you mark +a task DONE. + +This document is the reference for contributors working on the +scheduler and for users who want the full behavior specification. The +short, user-facing version lives in +[logseq/docs `Tasks.md`](https://github.com/logseq/docs/blob/master/pages/Tasks.md). + +## The three semantics + +### `.+` — Advance from completion + +> *Repeats from the last time you marked the block done.* + +The next occurrence is one interval after the moment you marked the +task DONE, regardless of what the original scheduled date was. + +**Best for:** habits and cadence-driven recurrences — things where the +clock restarts the moment you finish. + +**Example:** A weekly task scheduled for 2026-04-01, completed on +2026-04-05, will next appear on 2026-04-12. If you'd forgotten and +completed it on 2026-04-20, it would next appear on 2026-04-27. The +interval is anchored to your completion, not to the calendar. + +### `++` — Advance from scheduled, skip to future + +> *Keeps it on the same day of the week.* + +The next occurrence starts at *original + one interval* and keeps +advancing in whole intervals until the result is strictly in the +future. Because the arithmetic is in UTC and advances by whole weeks +(or months, or years), weekly recurrences naturally land on the same +day-of-week as the original. + +**Best for:** calendar-anchored recurrences where completion is a side +event and the anchor matters — "every Monday standup," "monthly review +on the first of the month." + +**Example:** A weekly standup scheduled for Monday, 2026-04-06: +- Completed Wednesday, 2026-04-08 → next occurrence Monday, 2026-04-13. +- Completed Friday, 2026-04-17 → next occurrence Monday, 2026-04-20. + +**This is the default** when a recurring task has no explicit cookie. + +### `+` — Advance from scheduled, stacking + +> *Repeats in X y/m/w/d/h from when you originally scheduled it.* + +The next occurrence is exactly *original + one interval*. If +completion was much later than the original date, the next occurrence +may land in the past — which causes the task to appear overdue +immediately. Org-mode calls this "stacking." + +**Best for:** obligations tied to an exact calendar date that should +not shift when you're late — monthly rent, annual renewals, fixed +billing cycles. + +**Example:** Rent due 2026-04-01, paid 2026-04-05 → next reminder +2026-05-01. Paid 2026-04-25 → next reminder still 2026-05-01. + +## Choosing the right cookie + +A decision table for picking the right semantic: + +| Intent | Cookie | +|---|---| +| "Every 7 days from when I last did it" | `.+` | +| "Every Monday" | `++` | +| "The 1st of every month" | `+` | + +## Default and backward compatibility + +Recurring tasks without an explicit `repeat-type` default to `++`. +This preserves the behavior that existed before the three cookies were +honored independently; no existing task changes on upgrade. Users who +want `.+` or `+` semantics can pick them via the repeat-setting +popover on the task. + +## Implementation reference + +### Scheduler + +The scheduler lives in `src/main/frontend/worker/commands.cljs`. It +dispatches on `repeat-type` via `repeat-next-timestamp`: + +- `advance-from-completion` implements `.+` +- `advance-from-scheduled` implements `+` +- `advance-until-future` implements `++` + +The dispatch falls back to `++` for `nil` or unknown values, so the +scheduler is safe against missing or future-added cookie types. +Frequencies of zero or less short-circuit to `nil` before dispatch to +avoid infinite loops. + +### Property + +`:logseq.property.repeat/repeat-type` is a closed-values property +declared in `deps/db/src/logseq/db/frontend/property.cljs`. Its closed +values are: + +- `:logseq.property.repeat/repeat-type.dotted-plus` — `.+` +- `:logseq.property.repeat/repeat-type.plus` — `+` +- `:logseq.property.repeat/repeat-type.double-plus` — `++` (default) + +### UI + +The repeat-setting popover in +`src/main/frontend/components/property/value.cljs` exposes a "Next date" +selector bound to the `repeat-type` property. It appears whenever the +task's repeat checkbox is enabled. + +### Tests + +Scheduler tests live in +`src/test/frontend/worker/commands_test.cljs`. Each of the three +semantics has a dedicated `deftest`; the preexisting +`get-next-time-test` exercises the default (`++`) path. Time is pinned +via `with-redefs [t/now ...]` for determinism. + +## Related reading + +- End-user docs: [logseq/docs `Tasks.md`](https://github.com/logseq/docs/blob/master/pages/Tasks.md) +- Org-mode spec: [Repeated tasks](https://orgmode.org/manual/Repeated-tasks.html) +- Tracking issues: [#7731](https://github.com/logseq/logseq/issues/7731), [#11260](https://github.com/logseq/logseq/issues/11260), [#6715](https://github.com/logseq/logseq/issues/6715), [#8531](https://github.com/logseq/logseq/issues/8531) diff --git a/src/main/frontend/components/property/value.cljs b/src/main/frontend/components/property/value.cljs index 32c9b307e5..6e2aae274d 100644 --- a/src/main/frontend/components/property/value.cljs +++ b/src/main/frontend/components/property/value.cljs @@ -275,6 +275,10 @@ ;; recur unit [:div.w-20 (property-value block (db/entity :logseq.property.repeat/recur-unit) (assoc opts :property property))]] + [:div.flex.flex-col.gap-1.min-w-0.ls-repeat-type-setting + [:div.text-muted-foreground + (t :property.repeat/next-date)] + (property-value block (db/entity :logseq.property.repeat/repeat-type) opts)] (let [properties (->> (outliner-property/get-block-full-properties (db/get-db) (:db/id block)) (filter (fn [property] diff --git a/src/main/frontend/components/property/value.css b/src/main/frontend/components/property/value.css index 666a4daf85..4c2f8250d8 100644 --- a/src/main/frontend/components/property/value.css +++ b/src/main/frontend/components/property/value.css @@ -51,6 +51,20 @@ min-width: 3em; } +.ls-repeat-type-setting { + .property-value-inner { + @apply border rounded px-2 py-1 max-w-full min-w-0; + } + + .jtrigger, .select-item { + @apply max-w-full min-w-0; + } + + .select-item { + @apply !whitespace-normal break-words leading-snug; + } +} + .ls-properties-area .empty-btn { @apply !text-base; } diff --git a/src/main/frontend/worker/commands.cljs b/src/main/frontend/worker/commands.cljs index 7f58285701..df892248f2 100644 --- a/src/main/frontend/worker/commands.cljs +++ b/src/main/frontend/worker/commands.cljs @@ -108,32 +108,54 @@ (defmulti handle-command (fn [action-id & _others] action-id)) -(defn- repeat-until-future-timestamp - [datetime recur-unit frequency period-f keep-week?] +(defn- advance-from-completion + "`.+` semantics: next occurrence = now + frequency * unit." + [recur-unit frequency] + (t/plus (t/now) (recur-unit frequency))) + +(defn- advance-from-scheduled + "`+` semantics: next occurrence = scheduled + frequency * unit. Can land in + the past if completion was long after scheduled — that's the documented + behavior (\"can stack overdue\")." + [datetime recur-unit frequency] + (t/plus datetime (recur-unit frequency))) + +(defn- advance-until-future + "`++` semantics: advance from scheduled in frequency*unit steps until strictly + after now. cljs-time arithmetic is UTC, so adding whole weeks preserves + day-of-week by construction — no fix-up needed." + [datetime recur-unit period-f frequency] (let [now (t/now) - v (max - 1 - (if (t/after? datetime now) - 1 - (period-f (t/interval datetime now)))) - delta (->> (Math/ceil (/ v frequency)) + periods (max 1 + (if (t/after? datetime now) + 1 + (period-f (t/interval datetime now)))) + delta (->> (Math/ceil (/ periods frequency)) (* frequency) recur-unit) - result* (t/plus datetime delta) - result (if (t/after? result* now) - result* - (t/plus result* (recur-unit frequency))) - w1 (t/day-of-week datetime) - w2 (t/day-of-week result)] - (if (and keep-week? (not= w1 w2)) - ;; next week - (if (> w2 w1) - (t/plus result (t/days (- 7 (- w2 w1)))) - (t/plus result (t/days (- w1 w2)))) - result))) + result (t/plus datetime delta)] + (loop [candidate result] + (if (t/after? candidate now) + candidate + (recur (t/plus candidate (recur-unit frequency))))))) + +(defn- repeat-next-timestamp + "Dispatch on repeat-type db-ident to compute the next occurrence. Mirrors the + three org-mode repeater cookies documented at + docs.logseq.com: `.+` (dotted-plus), `+` (plus), `++` (double-plus)." + [datetime recur-unit period-f frequency repeat-type] + (case repeat-type + :logseq.property.repeat/repeat-type.dotted-plus + (advance-from-completion recur-unit frequency) + + :logseq.property.repeat/repeat-type.plus + (advance-from-scheduled datetime recur-unit frequency) + + ;; :double-plus or unknown fallback + (advance-until-future datetime recur-unit period-f frequency))) (defn- get-next-time - [current-value unit frequency] + [current-value unit frequency repeat-type] (let [current-date-time (tc/to-date-time current-value) [recur-unit period-f] (case (:db/ident unit) :logseq.property.repeat/recur-unit.minute [t/minutes t/in-minutes] @@ -143,23 +165,41 @@ :logseq.property.repeat/recur-unit.month [t/months t/in-months] :logseq.property.repeat/recur-unit.year [t/years t/in-years] nil)] - (when recur-unit - (let [week? (= (:db/ident unit) :logseq.property.repeat/recur-unit.week) - next-time (repeat-until-future-timestamp current-date-time recur-unit frequency period-f week?)] - (tc/to-long next-time))))) + ;; Guard against frequency <= 0: `advance-until-future` would infinite-loop + ;; on zero-length intervals, and the other variants produce nonsense. + (when (and recur-unit (pos? frequency)) + (tc/to-long (repeat-next-timestamp current-date-time recur-unit period-f frequency repeat-type))))) + +(defn- resolve-recur-frequency + "Returns `[frequency default-value-tx-data]` for a recurring task entity: + + - `[n nil]` when the entity already has an explicit + `:logseq.property.repeat/recur-frequency` value `n`. + - `[1 tx-data]` when it doesn't — tx-data populates the property's + default-value block so subsequent reads see 1. + + The explicit-vs-default branch was previously guarded by `(or [A B] [C D])`, + which always selected the first branch because any 2-vector is truthy in + Clojure. That made the default-value path unreachable and left migrated + recurring tasks without a resolvable frequency. This form checks the value + explicitly via `if-let`." + [db entity] + (if-let [freq (db-property/property-value-content + (:logseq.property.repeat/recur-frequency entity))] + [freq nil] + (let [property (d/entity db :logseq.property.repeat/recur-frequency) + default-value-block (db-property-build/build-property-value-block property property 1) + default-value-tx-data [default-value-block + {:db/id (:db/id property) + :logseq.property/default-value [:block/uuid (:block/uuid default-value-block)]}]] + [1 default-value-tx-data]))) (defn- compute-reschedule-property-tx [db entity property-ident] - (let [[frequency default-value-tx-data] - (or [(db-property/property-value-content (:logseq.property.repeat/recur-frequency entity)) - nil] - (let [property (d/entity db :logseq.property.repeat/recur-frequency) - default-value-block (db-property-build/build-property-value-block property property 1) - default-value-tx-data [default-value-block - {:db/id (:db/id property) - :logseq.property/default-value [:block/uuid (:block/uuid default-value-block)]}]] - [1 default-value-tx-data])) + (let [[frequency default-value-tx-data] (resolve-recur-frequency db entity) unit (:logseq.property.repeat/recur-unit entity) + repeat-type (or (:db/ident (:logseq.property.repeat/repeat-type entity)) + :logseq.property.repeat/repeat-type.double-plus) property (d/entity db property-ident) date? (= :date (:logseq.property/type property)) current-value (cond-> @@ -167,7 +207,7 @@ date? (#(date-time-util/journal-day->ms (:block/journal-day %))))] (when (and frequency unit) - (when-let [next-time-long (get-next-time current-value unit frequency)] + (when-let [next-time-long (get-next-time current-value unit frequency repeat-type)] (let [journal-day (outliner-pipeline/get-journal-day-from-long db next-time-long) {:keys [tx-data page-uuid]} (if journal-day {:page-uuid (:block/uuid (d/entity db journal-day))} diff --git a/src/main/frontend/worker/db/migrate.cljs b/src/main/frontend/worker/db/migrate.cljs index 989c6cb167..e136855858 100644 --- a/src/main/frontend/worker/db/migrate.cljs +++ b/src/main/frontend/worker/db/migrate.cljs @@ -98,7 +98,8 @@ :logseq.property.recycle/original-order]}] ["65.25" {:delete-properties [:block/pre-block? :logseq.property.embedding/hnsw-label - :logseq.property.embedding/hnsw-label-updated-at]}]]) + :logseq.property.embedding/hnsw-label-updated-at]}] + ["65.26" {:properties [:logseq.property.repeat/repeat-type]}]]) (let [[major minor] (last (sort (map (comp (juxt :major :minor) db-schema/parse-schema-version first) schema-version->updates)))] diff --git a/src/resources/dicts/en.edn b/src/resources/dicts/en.edn index def3460aee..97e4b6cfdf 100644 --- a/src/resources/dicts/en.edn +++ b/src/resources/dicts/en.edn @@ -1434,6 +1434,7 @@ :property.built-in/repeat-checked-property "Repeating Checked Property" :property.built-in/repeat-recur-frequency "Repeating recur frequency" :property.built-in/repeat-recur-unit "Repeating recur unit" + :property.built-in/repeat-repeat-type "Repeating type" :property.built-in/repeat-repeated "Node Repeats?" :property.built-in/repeat-temporal-property "Repeating Temporal Property" :property.built-in/scalar-default-value "Non ref type default value" @@ -1484,6 +1485,7 @@ :property.repeat/datetime "Repeat datetime" :property.repeat/every "Every" :property.repeat/is-label "is:" + :property.repeat/next-date "Next date" :property.repeat/task "Repeat task" :property.repeat/when "When" @@ -1494,6 +1496,10 @@ :property.repeat-recur-unit/week "Week" :property.repeat-recur-unit/year "Year" + :property.repeat-repeat-type/dotted-plus "Advance from completion" + :property.repeat-repeat-type/double-plus "Advance from scheduled, skip to future" + :property.repeat-repeat-type/plus "Advance from scheduled" + :property.status/backlog "Backlog" :property.status/canceled "Canceled" :property.status/doing "Doing" diff --git a/src/resources/dicts/zh-cn.edn b/src/resources/dicts/zh-cn.edn index bfd40a4ac4..11252a0f0d 100644 --- a/src/resources/dicts/zh-cn.edn +++ b/src/resources/dicts/zh-cn.edn @@ -1423,6 +1423,7 @@ :property.built-in/repeat-checked-property "重复的选中属性" :property.built-in/repeat-recur-frequency "重复频率" :property.built-in/repeat-recur-unit "重复单位" + :property.built-in/repeat-repeat-type "重复类型" :property.built-in/repeat-repeated "节点是否重复?" :property.built-in/repeat-temporal-property "重复的时间属性" :property.built-in/scalar-default-value "非引用类型默认值" @@ -1473,6 +1474,7 @@ :property.repeat/datetime "重复日期时间" :property.repeat/every "每" :property.repeat/is-label "是:" + :property.repeat/next-date "下次日期" :property.repeat/task "重复任务" :property.repeat/when "当" @@ -1483,6 +1485,10 @@ :property.repeat-recur-unit/week "周" :property.repeat-recur-unit/year "年" + :property.repeat-repeat-type/dotted-plus "从完成时间推进" + :property.repeat-repeat-type/double-plus "从计划时间推进,跳到未来" + :property.repeat-repeat-type/plus "从计划时间推进" + :property.status/backlog "待办列表" :property.status/canceled "已取消" :property.status/doing "进行中" diff --git a/src/test/frontend/worker/commands_test.cljs b/src/test/frontend/worker/commands_test.cljs index 18c3a807ee..e29c767637 100644 --- a/src/test/frontend/worker/commands_test.cljs +++ b/src/test/frontend/worker/commands_test.cljs @@ -2,9 +2,19 @@ (:require [cljs-time.coerce :as tc] [cljs-time.core :as t] [cljs.test :refer [deftest is testing]] - [frontend.worker.commands :as commands])) + [datascript.core :as d] + [frontend.worker.commands :as commands] + [logseq.db.frontend.property :as db-property] + [logseq.db.frontend.property.build :as db-property-build])) + +(defn- get-next-time + "Test helper. Three-arg form uses the `:double-plus` default (preserves prior + test expectations); four-arg form passes the repeat-type explicitly." + ([current-value unit frequency] + (get-next-time current-value unit frequency :logseq.property.repeat/repeat-type.double-plus)) + ([current-value unit frequency repeat-type] + (#'commands/get-next-time current-value unit frequency repeat-type))) -(def get-next-time #'commands/get-next-time) (def minute-unit {:db/ident :logseq.property.repeat/recur-unit.minute}) (def hour-unit {:db/ident :logseq.property.repeat/recur-unit.hour}) (def day-unit {:db/ident :logseq.property.repeat/recur-unit.day}) @@ -12,7 +22,11 @@ (def month-unit {:db/ident :logseq.property.repeat/recur-unit.month}) (def year-unit {:db/ident :logseq.property.repeat/recur-unit.year}) -(deftest ^:large-vars/cleanup-todo ^:fix-me get-next-time-test +(def dotted-plus :logseq.property.repeat/repeat-type.dotted-plus) +(def plus :logseq.property.repeat/repeat-type.plus) +(def double-plus :logseq.property.repeat/repeat-type.double-plus) + +(deftest ^:large-vars/cleanup-todo get-next-time-test (let [now (t/now) one-minute-ago (t/minus now (t/minutes 1)) one-hour-ago (t/minus now (t/hours 1)) @@ -26,97 +40,295 @@ in-weeks (fn [next-time] (/ (- next-time now) (* 1000 60 60 24 7))) in-months (fn [next-time] (t/in-months (t/interval now (tc/from-long next-time)))) in-years (fn [next-time] (t/in-years (t/interval now (tc/from-long next-time))))] - (testing "basic test for get-next-time" - ;; minute - (let [next-time (get-next-time now minute-unit 1)] - (is (= 1 (in-minutes next-time)))) - (let [next-time (get-next-time one-minute-ago minute-unit 1)] - (is (= 1 (in-minutes next-time)))) - (let [next-time (get-next-time one-minute-ago minute-unit 3)] - (is (= 2 (in-minutes next-time)))) - (let [next-time (get-next-time one-minute-ago minute-unit 5)] - (is (= 4 (in-minutes next-time)))) + (with-redefs [t/now (fn [] now)] + (testing "basic test for get-next-time (default :double-plus semantics)" + ;; minute + (let [next-time (get-next-time now minute-unit 1)] + (is (= 1 (in-minutes next-time)))) + (let [next-time (get-next-time one-minute-ago minute-unit 1)] + (is (= 1 (in-minutes next-time)))) + (let [next-time (get-next-time one-minute-ago minute-unit 3)] + (is (= 2 (in-minutes next-time)))) + (let [next-time (get-next-time one-minute-ago minute-unit 5)] + (is (= 4 (in-minutes next-time)))) - ;; hour - (let [next-time (get-next-time now hour-unit 1)] - (is (= 1 (in-hours next-time)))) - (let [next-time (get-next-time one-hour-ago hour-unit 1)] - (is (= 1 (in-hours next-time)))) - (let [next-time (get-next-time one-hour-ago hour-unit 3)] - (is (= 2 (in-hours next-time)))) - (let [next-time (get-next-time one-hour-ago hour-unit 5)] - (is (= 4 (in-hours next-time)))) + ;; hour + (let [next-time (get-next-time now hour-unit 1)] + (is (= 1 (in-hours next-time)))) + (let [next-time (get-next-time one-hour-ago hour-unit 1)] + (is (= 1 (in-hours next-time)))) + (let [next-time (get-next-time one-hour-ago hour-unit 3)] + (is (= 2 (in-hours next-time)))) + (let [next-time (get-next-time one-hour-ago hour-unit 5)] + (is (= 4 (in-hours next-time)))) - ;; day - (let [next-time (get-next-time now day-unit 1)] - (is (= 1 (in-days next-time)))) - (let [next-time (get-next-time one-day-ago day-unit 1)] - (is (= 1 (in-days next-time)))) - (let [next-time (get-next-time one-day-ago day-unit 3)] - (is (= 2 (in-days next-time)))) - (let [next-time (get-next-time one-day-ago day-unit 5)] - (is (= 4 (in-days next-time)))) + ;; day + (let [next-time (get-next-time now day-unit 1)] + (is (= 1 (in-days next-time)))) + (let [next-time (get-next-time one-day-ago day-unit 1)] + (is (= 1 (in-days next-time)))) + (let [next-time (get-next-time one-day-ago day-unit 3)] + (is (= 2 (in-days next-time)))) + (let [next-time (get-next-time one-day-ago day-unit 5)] + (is (= 4 (in-days next-time)))) - ;; week - (let [next-time (get-next-time now week-unit 1)] - (is (= 1 (in-weeks next-time)))) - (let [next-time (get-next-time one-week-ago week-unit 1)] - (is (= 1 (in-weeks next-time)))) - (let [next-time (get-next-time one-week-ago week-unit 3)] - (is (= 2 (in-weeks next-time)))) - (let [next-time (get-next-time one-week-ago week-unit 5)] - (is (= 4 (in-weeks next-time)))) + ;; week + (let [next-time (get-next-time now week-unit 1)] + (is (= 1 (in-weeks next-time)))) + (let [next-time (get-next-time one-week-ago week-unit 1)] + (is (= 1 (in-weeks next-time)))) + (let [next-time (get-next-time one-week-ago week-unit 3)] + (is (= 2 (in-weeks next-time)))) + (let [next-time (get-next-time one-week-ago week-unit 5)] + (is (= 4 (in-weeks next-time)))) - ;; month - (let [next-time (get-next-time now month-unit 1)] - (is (= 1 (in-months next-time)))) - (let [next-time (get-next-time one-month-ago month-unit 1)] - (is (> (in-days next-time) 1))) - (let [next-time (get-next-time one-month-ago month-unit 3)] - (is (contains? #{1 2} (in-months next-time)))) - (let [next-time (get-next-time one-month-ago month-unit 5)] - (is (contains? #{3 4} (in-months next-time)))) + ;; month + (let [next-time (get-next-time now month-unit 1)] + (is (= 1 (in-months next-time)))) + (let [next-time (get-next-time one-month-ago month-unit 1)] + (is (> (in-days next-time) 1))) + (let [next-time (get-next-time one-month-ago month-unit 3)] + (is (contains? #{1 2} (in-months next-time)))) + (let [next-time (get-next-time one-month-ago month-unit 5)] + (is (contains? #{3 4} (in-months next-time)))) - ;; year - (let [next-time (get-next-time now year-unit 1)] - (is (= 1 (in-years next-time)))) - (let [next-time (get-next-time one-year-ago year-unit 1)] - (is (= 1 (in-years next-time)))) - (let [next-time (get-next-time one-year-ago year-unit 3)] - (is (= 2 (in-years next-time)))) - (let [next-time (get-next-time one-year-ago year-unit 5)] - (is (= 4 (in-years next-time))))) + ;; year + (let [next-time (get-next-time now year-unit 1)] + (is (= 1 (in-years next-time)))) + (let [next-time (get-next-time one-year-ago year-unit 1)] + (is (= 1 (in-years next-time)))) + (let [next-time (get-next-time one-year-ago year-unit 3)] + (is (= 2 (in-years next-time)))) + (let [next-time (get-next-time one-year-ago year-unit 5)] + (is (= 4 (in-years next-time))))) - (testing "preserves week day" - (let [next-time (get-next-time now week-unit 1)] - (is (= (t/day-of-week (tc/from-long next-time)) (t/day-of-week now)))) - (let [next-time (get-next-time one-week-ago week-unit 1)] - (is (= (t/day-of-week (tc/from-long next-time)) (t/day-of-week now))))) + (testing "preserves week day (default :double-plus)" + (let [next-time (get-next-time now week-unit 1)] + (is (= (t/day-of-week (tc/from-long next-time)) (t/day-of-week now)))) + (let [next-time (get-next-time one-week-ago week-unit 1)] + (is (= (t/day-of-week (tc/from-long next-time)) (t/day-of-week now))))) - (testing "schedule on future time should move it to the next one" - (let [next-time (get-next-time (t/plus now (t/minutes 10)) minute-unit 1)] - (is (= 11 (in-minutes next-time)))) - (let [next-time (get-next-time (t/plus now (t/hours 10)) hour-unit 1)] - (is (= 11 (in-hours next-time)))) - (let [next-time (get-next-time (t/plus now (t/days 10)) day-unit 1)] - (is (= 11 (in-days next-time)))) - (let [next-time (get-next-time (t/plus now (t/weeks 10)) week-unit 1)] - (is (= 11 (in-weeks next-time)))) - (let [next-time (get-next-time (t/plus now (t/months 10)) month-unit 1)] - (is (contains? #{10 11} (in-months next-time)))) - (let [next-time (get-next-time (t/plus now (t/years 10)) year-unit 1)] - (is (= 11 (in-years next-time))))) + (testing "schedule on future time should move it to the next one" + (let [next-time (get-next-time (t/plus now (t/minutes 10)) minute-unit 1)] + (is (= 11 (in-minutes next-time)))) + (let [next-time (get-next-time (t/plus now (t/hours 10)) hour-unit 1)] + (is (= 11 (in-hours next-time)))) + (let [next-time (get-next-time (t/plus now (t/days 10)) day-unit 1)] + (is (= 11 (in-days next-time)))) + (let [next-time (get-next-time (t/plus now (t/weeks 10)) week-unit 1)] + (is (= 11 (in-weeks next-time)))) + (let [next-time (get-next-time (t/plus now (t/months 10)) month-unit 1)] + ;; Lenient assertion adopted from upstream — `t/in-months` can return + ;; 10 or 11 around month-end boundaries even with `t/now` pinned. + (is (contains? #{10 11} (in-months next-time)))) + (let [next-time (get-next-time (t/plus now (t/years 10)) year-unit 1)] + (is (= 11 (in-years next-time))))) - (testing "schedule on past time should move it to future" - (let [next-time (get-next-time (t/minus now (t/minutes 10)) minute-unit 1)] - (is (= 1 (in-minutes next-time)))) - (let [next-time (get-next-time (t/minus now (t/hours 10)) hour-unit 1)] - (is (= 1 (in-hours next-time)))) - (let [next-time (get-next-time (t/minus now (t/days 10)) day-unit 1)] - (is (= 1 (in-days next-time)))) - (let [next-time (get-next-time (t/minus now (t/weeks 10)) week-unit 1)] - (is (= 1 (in-weeks next-time)))) - (let [next-time (get-next-time (t/minus now (t/months 10)) month-unit 1)] - (is (> (in-days next-time) 1))) - (let [next-time (get-next-time (t/minus now (t/years 10)) year-unit 1)] - (is (= 1 (in-years next-time))))))) + (testing "schedule on past time should move it to future" + (let [next-time (get-next-time (t/minus now (t/minutes 10)) minute-unit 1)] + (is (= 1 (in-minutes next-time)))) + (let [next-time (get-next-time (t/minus now (t/hours 10)) hour-unit 1)] + (is (= 1 (in-hours next-time)))) + (let [next-time (get-next-time (t/minus now (t/days 10)) day-unit 1)] + (is (= 1 (in-days next-time)))) + (let [next-time (get-next-time (t/minus now (t/weeks 10)) week-unit 1)] + (is (= 1 (in-weeks next-time)))) + (let [next-time (get-next-time (t/minus now (t/months 10)) month-unit 1)] + (is (> (in-days next-time) 1))) + (let [next-time (get-next-time (t/minus now (t/years 10)) year-unit 1)] + (is (= 1 (in-years next-time)))))))) + +(deftest dotted-plus-advances-from-completion-test + (testing "`.+` always anchors on now (completion), regardless of original schedule" + (let [now (t/now) + in-days (fn [next-time] (/ (- next-time now) (* 1000 60 60 24))) + in-weeks (fn [next-time] (/ (- next-time now) (* 1000 60 60 24 7))) + in-years (fn [next-time] (t/in-years (t/interval now (tc/from-long next-time))))] + (with-redefs [t/now (fn [] now)] + ;; Weekly task scheduled 4 days ago and completed today: + ;; `.+1w` expects completion + 1 week (7 days from now), not original + 1 week (3 days from now). + (let [four-days-ago (t/minus now (t/days 4)) + next-time (get-next-time four-days-ago week-unit 1 dotted-plus)] + (is (= 7 (in-days next-time)))) + ;; Weekly completed long after original: still completion + 1 week. + (let [ten-weeks-ago (t/minus now (t/weeks 10)) + next-time (get-next-time ten-weeks-ago week-unit 1 dotted-plus)] + (is (= 1 (in-weeks next-time)))) + ;; Monthly scheduled 10 days ago: completion + 1 month (28–31 days). + (let [ten-days-ago (t/minus now (t/days 10)) + next-time (get-next-time ten-days-ago month-unit 1 dotted-plus)] + (is (> (in-days next-time) 27))) + ;; Yearly scheduled 3 months ago: completion + 1 year. + (let [three-months-ago (t/minus now (t/months 3)) + next-time (get-next-time three-months-ago year-unit 1 dotted-plus)] + (is (= 1 (in-years next-time)))) + ;; Even when the original is in the future, `.+` still anchors on now — + ;; this is the deliberate semantic: the completion moment becomes the new anchor. + (let [three-days-future (t/plus now (t/days 3)) + next-time (get-next-time three-days-future week-unit 1 dotted-plus)] + (is (= 7 (in-days next-time)))))))) + +(deftest plus-advances-from-scheduled-test + (testing "`+` advances from the original scheduled date; can land in the past (documented stacking)" + (let [now (t/now) + in-days (fn [next-time] (/ (- next-time now) (* 1000 60 60 24))) + in-weeks (fn [next-time] (/ (- next-time now) (* 1000 60 60 24 7)))] + (with-redefs [t/now (fn [] now)] + ;; Weekly scheduled 10 days ago: result = original + 1 week = 3 days ago (stacked). + (let [ten-days-ago (t/minus now (t/days 10)) + next-time (get-next-time ten-days-ago week-unit 1 plus)] + (is (= -3 (in-days next-time)))) + ;; Weekly scheduled 4 days ago: result = original + 1 week = 3 days from now. + (let [four-days-ago (t/minus now (t/days 4)) + next-time (get-next-time four-days-ago week-unit 1 plus)] + (is (= 3 (in-days next-time)))) + ;; Future-scheduled: result = original + 1 week (no completion adjustment). + (let [three-days-future (t/plus now (t/days 3)) + next-time (get-next-time three-days-future week-unit 1 plus)] + (is (= 10 (in-days next-time)))) + ;; Every-3-weeks, scheduled 5 weeks ago: result = original + 3 weeks = 2 weeks ago. + (let [five-weeks-ago (t/minus now (t/weeks 5)) + next-time (get-next-time five-weeks-ago week-unit 3 plus)] + (is (= -2 (in-weeks next-time)))))))) + +(deftest double-plus-advances-until-future-test + (testing "`++` advances in whole intervals until strictly after now; preserves weekday for weekly" + (let [now (t/now) + in-days (fn [next-time] (/ (- next-time now) (* 1000 60 60 24))) + in-weeks (fn [next-time] (/ (- next-time now) (* 1000 60 60 24 7)))] + (with-redefs [t/now (fn [] now)] + ;; Weekly scheduled 10 days ago: original + 1 week = 3 days ago (still past), + ;; step again to original + 2 weeks = 4 days from now. + (let [ten-days-ago (t/minus now (t/days 10)) + next-time (get-next-time ten-days-ago week-unit 1 double-plus)] + (is (= 4 (in-days next-time)))) + ;; Weekly scheduled 4 days ago: original + 1 week = 3 days from now (already future). + (let [four-days-ago (t/minus now (t/days 4)) + next-time (get-next-time four-days-ago week-unit 1 double-plus)] + (is (= 3 (in-days next-time)))) + ;; Weekday preservation: landed occurrence should match the scheduled day-of-week. + (let [ten-days-ago (t/minus now (t/days 10)) + next-time (get-next-time ten-days-ago week-unit 1 double-plus)] + (is (= (t/day-of-week (tc/from-long next-time)) + (t/day-of-week ten-days-ago)))) + ;; Every-3-weeks: scheduled 5 weeks ago → original + 6 weeks = 1 week from now. + (let [five-weeks-ago (t/minus now (t/weeks 5)) + next-time (get-next-time five-weeks-ago week-unit 3 double-plus)] + (is (= 1 (in-weeks next-time)))))))) + +(deftest repeat-type-defaults-to-double-plus-test + (testing "Missing/unknown repeat-type falls back to :double-plus (preserves prior behavior on upgrade)" + (let [now (t/now) + in-days (fn [next-time] (/ (- next-time now) (* 1000 60 60 24))) + ten-days-ago (t/minus now (t/days 10))] + (with-redefs [t/now (fn [] now)] + (let [via-nil (get-next-time ten-days-ago week-unit 1 nil) + via-default (get-next-time ten-days-ago week-unit 1 double-plus)] + (is (= via-nil via-default)) + (is (= 4 (in-days via-nil)))))))) + +(deftest get-next-time-rejects-non-positive-frequency-test + (testing "Frequency of 0 or negative returns nil instead of looping or producing nonsense" + (let [now (t/now) + ten-days-ago (t/minus now (t/days 10))] + (with-redefs [t/now (fn [] now)] + (is (nil? (get-next-time ten-days-ago week-unit 0))) + (is (nil? (get-next-time ten-days-ago week-unit -1))) + (is (nil? (get-next-time ten-days-ago week-unit 0 dotted-plus))) + (is (nil? (get-next-time ten-days-ago week-unit -2 plus))))))) + +(deftest get-next-time-rejects-unknown-unit-test + (testing "Unknown unit returns nil" + (let [now (t/now)] + (with-redefs [t/now (fn [] now)] + (is (nil? (get-next-time now {} 1))) + (is (nil? (get-next-time now {:db/ident :bogus} 1 dotted-plus))))))) + +(deftest dotted-plus-frequency-greater-than-one-test + (testing "`.+` with frequency > 1 across units" + (let [now (t/now) + in-minutes (fn [next-time] (/ (- next-time now) (* 1000 60))) + in-days (fn [next-time] (/ (- next-time now) (* 1000 60 60 24))) + in-weeks (fn [next-time] (/ (- next-time now) (* 1000 60 60 24 7)))] + (with-redefs [t/now (fn [] now)] + (let [next-time (get-next-time (t/minus now (t/hours 5)) minute-unit 15 dotted-plus)] + (is (= 15 (in-minutes next-time)))) + (let [next-time (get-next-time (t/minus now (t/days 2)) day-unit 5 dotted-plus)] + (is (= 5 (in-days next-time)))) + (let [next-time (get-next-time (t/minus now (t/weeks 1)) week-unit 3 dotted-plus)] + (is (= 3 (in-weeks next-time)))))))) + +(deftest double-plus-month-and-year-test + (testing "`++` on month/year units also lands strictly in the future" + (let [now (t/now) + in-months (fn [next-time] (t/in-months (t/interval now (tc/from-long next-time)))) + in-years (fn [next-time] (t/in-years (t/interval now (tc/from-long next-time))))] + (with-redefs [t/now (fn [] now)] + ;; Monthly scheduled 2 months ago, frequency 1: original + 3 months = 1 month from now. + (let [two-months-ago (t/minus now (t/months 2)) + next-time (get-next-time two-months-ago month-unit 1 double-plus)] + (is (contains? #{0 1} (in-months next-time)))) + ;; Every-3-months, scheduled 5 months ago: original + 6 months = 1 month from now. + (let [five-months-ago (t/minus now (t/months 5)) + next-time (get-next-time five-months-ago month-unit 3 double-plus)] + (is (contains? #{0 1} (in-months next-time)))) + ;; Yearly scheduled 2 years ago: original + 3 years = 1 year from now. + (let [two-years-ago (t/minus now (t/years 2)) + next-time (get-next-time two-years-ago year-unit 1 double-plus)] + (is (= 1 (in-years next-time)))))))) + +(deftest double-plus-month-clamp-stays-future-test + (testing "`++` keeps advancing after month-end clamping until the result is future" + (let [now (t/date-time 2026 3 30) + scheduled (t/date-time 2026 1 31)] + (with-redefs [t/now (fn [] now)] + (is (t/after? (tc/from-long (get-next-time scheduled month-unit 1 double-plus)) + now)))))) + +(deftest double-plus-far-overdue-minute-is-bounded-test + (testing "`++` does not advance far-overdue minute repeats one interval at a time" + (let [now (t/now) + two-years-ago (t/minus now (t/years 2)) + unit-calls (atom 0) + minutes (fn [frequency] + (swap! unit-calls inc) + (when (> @unit-calls 1000) + (throw (ex-info "Too many recurrence unit calls" {:calls @unit-calls}))) + (t/minutes frequency)) + result (with-redefs [t/now (fn [] now)] + (try + (#'commands/repeat-next-timestamp two-years-ago minutes t/in-minutes 1 double-plus) + (catch :default e e)))] + (is (not (instance? js/Error result)) + "Far-overdue minutely repeats should compute without unbounded iteration") + (when-not (instance? js/Error result) + (is (= 1 (/ (- (tc/to-long result) (tc/to-long now)) (* 1000 60))))) + (is (< @unit-calls 20))))) + +(deftest resolve-recur-frequency-test + (let [resolve (fn [db entity] (#'commands/resolve-recur-frequency db entity))] + (testing "returns the explicit frequency when the property has a value" + (with-redefs [db-property/property-value-content (fn [_] 5)] + (let [[freq tx] (resolve :mock-db {})] + (is (= 5 freq)) + (is (nil? tx) + "no default-value tx needed when the property is already set")))) + + (testing "falls back to 1 and builds default-value tx when the property is unset" + ;; Regression guard for the previous `(or [A B] [C D])` pattern, which + ;; always selected the first branch (any 2-vector is truthy) and left + ;; the default-value path unreachable. `if-let` is what makes both + ;; branches reachable. + (with-redefs [db-property/property-value-content (fn [_] nil) + d/entity (fn [_ _] {:db/id 42 + :block/uuid #uuid "00000000-0000-0000-0000-000000000001"}) + db-property-build/build-property-value-block + (fn [_ _ value] {:block/uuid #uuid "00000000-0000-0000-0000-000000000002" + :logseq.property/value value})] + (let [[freq tx] (resolve :mock-db {})] + (is (= 1 freq) + "frequency defaults to 1 when the entity has no explicit value") + (is (some? tx) + "tx data is returned so the property's default-value block gets written") + (is (= 2 (count tx)) + "tx has the value block and the property-default wiring")))))) diff --git a/src/test/frontend/worker/migrate_test.cljs b/src/test/frontend/worker/migrate_test.cljs index a8766b4c78..c7305accf5 100644 --- a/src/test/frontend/worker/migrate_test.cljs +++ b/src/test/frontend/worker/migrate_test.cljs @@ -71,3 +71,21 @@ (is (nil? (d/entity @conn :logseq.property.embedding/hnsw-label-updated-at))) (is (= "legacy block" (:block/title (d/entity @conn [:block/uuid legacy-block-uuid])))))) + +(deftest migrate-65-25-adds-repeat-type-property + (let [conn (d/create-conn db-schema/schema)] + (d/transact! conn [{:db/ident :logseq.kv/schema-version + :kv/value {:major 65 :minor 25}}]) + + (db-migrate/migrate conn) + + (is (= db-schema/version + (:kv/value (d/entity @conn :logseq.kv/schema-version)))) + (let [property (d/entity @conn :logseq.property.repeat/repeat-type)] + (is (some? property)) + (is (= :logseq.property.repeat/repeat-type.double-plus + (:db/ident (:logseq.property/default-value property)))) + (is (= #{:logseq.property.repeat/repeat-type.dotted-plus + :logseq.property.repeat/repeat-type.plus + :logseq.property.repeat/repeat-type.double-plus} + (set (map :db/ident (:property/closed-values property))))))))