diff --git a/YKanBan.Tests/Models/CardOrderingTests.cs b/YKanBan.Tests/Models/CardOrderingTests.cs
new file mode 100644
index 0000000..afae657
--- /dev/null
+++ b/YKanBan.Tests/Models/CardOrderingTests.cs
@@ -0,0 +1,46 @@
+using YKanBan.Models;
+using YKanBan.Storage.AppData;
+
+namespace YKanBan.Tests.Models;
+
+///
+/// Tests for the in-column card display order: each sort option plus the id
+/// ascending tie-breaker.
+///
+[TestClass]
+public class CardOrderingTests
+{
+ private static CardModel Card(long id, string title = "", long updatedAt = 0) =>
+ new(id, 1, title, string.Empty, 0, updatedAt, []);
+
+ private static long[] Ids(IEnumerable cards, CardSortOption option) =>
+ CardOrdering.Sort(cards, option).Select(card => card.Id).ToArray();
+
+ [TestMethod]
+ public void IdSortsAscending()
+ {
+ CollectionAssert.AreEqual(new[] { 1L, 2L, 3L }, Ids([Card(3), Card(1), Card(2)], CardSortOption.Id));
+ }
+
+ [TestMethod]
+ public void UpdatedAtSortsMostRecentFirstThenById()
+ {
+ CardModel[] cards = [Card(1, updatedAt: 10), Card(2, updatedAt: 30), Card(3, updatedAt: 10), Card(4, updatedAt: 20)];
+ CollectionAssert.AreEqual(new[] { 2L, 4L, 1L, 3L }, Ids(cards, CardSortOption.UpdatedAt));
+ }
+
+ [TestMethod]
+ public void TitleSortsByCodePointWithEmptyFirstThenById()
+ {
+ CardModel[] cards = [Card(1, "b"), Card(2, "B"), Card(3, ""), Card(4, "a"), Card(5, "b")];
+ CollectionAssert.AreEqual(new[] { 3L, 2L, 4L, 1L, 5L }, Ids(cards, CardSortOption.Title));
+ }
+
+ [TestMethod]
+ public void TitleSortsSupplementaryCharactersAfterBmpPrivateUse()
+ {
+ // U+1F600 is a surrogate pair in UTF-16, so ordinal comparison would put it before U+E000.
+ CardModel[] cards = [Card(1, "\U0001F600"), Card(2, "")];
+ CollectionAssert.AreEqual(new[] { 2L, 1L }, Ids(cards, CardSortOption.Title));
+ }
+}
diff --git a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs
index 55b1915..3c06d46 100644
--- a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs
+++ b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs
@@ -145,7 +145,7 @@ public class AppConfigStoreTests
{
Language = "zh-Hans",
Theme = ThemeOption.Light,
- Sort = new SortSettings { Card = CardSortOption.CreatedAt },
+ Sort = new SortSettings { Card = CardSortOption.Title },
ColumnWidth = new ColumnWidthSettings { Preset = ColumnWidthPreset.Wide },
Confirmations = new ConfirmationSettings { DiscardEdit = false },
};
diff --git a/YKanBan/Models/CardOrdering.cs b/YKanBan/Models/CardOrdering.cs
new file mode 100644
index 0000000..fa9b7fd
--- /dev/null
+++ b/YKanBan/Models/CardOrdering.cs
@@ -0,0 +1,70 @@
+using System.Text;
+using YKanBan.Storage.AppData;
+
+namespace YKanBan.Models;
+
+///
+/// Computes the in-column card display order for a .
+/// Columns are not affected: they always stay in id order. Every option falls
+/// back to id ascending as the final tie-breaker, so the order is total.
+///
+public static class CardOrdering
+{
+ ///
+ /// Orders cards for display.
+ ///
+ /// The cards to order, typically those of one column.
+ /// The selected sort option.
+ /// The cards in display order.
+ public static IReadOnlyList Sort(IEnumerable cards, CardSortOption option)
+ {
+ IOrderedEnumerable ordered = option switch
+ {
+ CardSortOption.Id => cards.OrderBy(card => card.Id),
+
+ // Most recently modified first.
+ CardSortOption.UpdatedAt => cards.OrderByDescending(card => card.UpdatedAt).ThenBy(card => card.Id),
+
+ // Code point order, matching SQLite's BINARY collation; the empty title sorts first.
+ CardSortOption.Title => cards.OrderBy(card => card.Title, CodePointComparer.Instance).ThenBy(card => card.Id),
+
+ _ => throw new ArgumentOutOfRangeException(nameof(option), option, "Unknown card sort option."),
+ };
+ return ordered.ToList();
+ }
+
+ ///
+ /// Compares strings by Unicode code points. Unlike ordinal UTF-16 comparison
+ /// it orders supplementary characters after U+E000–U+FFFF, as BINARY does on UTF-8.
+ ///
+ private sealed class CodePointComparer : IComparer
+ {
+ public static readonly CodePointComparer Instance = new();
+
+ public int Compare(string? x, string? y)
+ {
+ if (x is null || y is null)
+ {
+ return x is null ? (y is null ? 0 : -1) : 1;
+ }
+
+ StringRuneEnumerator left = x.EnumerateRunes();
+ StringRuneEnumerator right = y.EnumerateRunes();
+ while (true)
+ {
+ bool hasLeft = left.MoveNext();
+ bool hasRight = right.MoveNext();
+ if (!hasLeft || !hasRight)
+ {
+ return hasLeft.CompareTo(hasRight);
+ }
+
+ int result = left.Current.Value.CompareTo(right.Current.Value);
+ if (result != 0)
+ {
+ return result;
+ }
+ }
+ }
+ }
+}
diff --git a/YKanBan/Storage/AppData/AppConfig.cs b/YKanBan/Storage/AppData/AppConfig.cs
index 4b1ed52..65beb6a 100644
--- a/YKanBan/Storage/AppData/AppConfig.cs
+++ b/YKanBan/Storage/AppData/AppConfig.cs
@@ -13,10 +13,6 @@ public enum CardSortOption
[EnumMember(Value = "id")]
Id,
- /// Creation time, oldest first.
- [EnumMember(Value = "created-at")]
- CreatedAt,
-
/// Last modification time, most recently modified first.
[EnumMember(Value = "updated-at")]
UpdatedAt,