mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
boards: last list means done, new lists go before it
Moving a card into the last list stamps done_at, moving it out clears it, so the done column and the tick can no longer disagree. :list inserts in front of the last list instead of after it.
This commit is contained in:
1 parent
ecfbab0eaa
commit
4ded89b0e7
1 file changed
+78
-11
+78
-11
@@ -6,6 +6,12 @@
|
||||
//! node directly. That keeps the deployment one binary and one SQLite file, and
|
||||
//! the Proton mirror keeps covering everything without a second service.
|
||||
//!
|
||||
//! The columns carry meaning by position, not by name: the first list is the
|
||||
//! inbox new cards land in, the last list is "done", and a card is finished
|
||||
//! exactly when it sits there. Moving a card into the last list stamps
|
||||
//! `done_at`, moving it out clears it, so a tick and a column can never
|
||||
//! disagree about whether something is finished.
|
||||
//!
|
||||
//! Cards may point at a document. The link is deliberately weak — deleting the
|
||||
//! document sets `node_id` to NULL rather than deleting the card, because the
|
||||
//! work is still open once its notes are gone.
|
||||
@@ -118,6 +124,35 @@ fn reordered(ids: &[i64], card: i64, to: usize) -> Vec<i64> {
|
||||
rest
|
||||
}
|
||||
|
||||
/// `done_at` after a card moves from list `from` to list `to` on a board whose
|
||||
/// last list is `last`. Shuffling inside one list changes nothing; arriving in
|
||||
/// the last list finishes the card (keeping an earlier stamp), leaving it
|
||||
/// reopens it.
|
||||
fn done_after_move(from: i64, to: i64, last: i64, done_at: Option<i64>, now: i64) -> Option<i64> {
|
||||
if from == to {
|
||||
done_at
|
||||
} else if to == last {
|
||||
done_at.or(Some(now))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The id and position of the last list of a space's board — its "done".
|
||||
async fn last_list(
|
||||
db: impl sqlx::SqliteExecutor<'_>,
|
||||
space_id: i64,
|
||||
) -> AppResult<Option<(i64, i64)>> {
|
||||
Ok(sqlx::query(
|
||||
"SELECT id, position FROM board_lists WHERE space_id = ?1
|
||||
ORDER BY position DESC, id DESC LIMIT 1",
|
||||
)
|
||||
.bind(space_id)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.map(|row| (row.get("id"), row.get("position"))))
|
||||
}
|
||||
|
||||
fn row_to_card(row: &sqlx::sqlite::SqliteRow) -> Card {
|
||||
Card {
|
||||
id: row.get("id"),
|
||||
@@ -263,13 +298,27 @@ pub async fn create_list(
|
||||
return Err(AppError::BadRequest("a list needs a name".into()));
|
||||
}
|
||||
|
||||
let position: i64 = sqlx::query(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 AS next FROM board_lists WHERE space_id = ?1",
|
||||
)
|
||||
.bind(space_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?
|
||||
.get("next");
|
||||
// A new column goes in front of the last one, because the last one is
|
||||
// "done" and has to stay last for that to keep meaning anything. A board
|
||||
// down to a single list just grows a second one after it.
|
||||
let count: i64 = sqlx::query("SELECT COUNT(*) AS n FROM board_lists WHERE space_id = ?1")
|
||||
.bind(space_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?
|
||||
.get("n");
|
||||
|
||||
let mut tx = state.db.begin().await?;
|
||||
let position = match last_list(&mut *tx, space_id).await? {
|
||||
Some((last, position)) if count >= 2 => {
|
||||
sqlx::query("UPDATE board_lists SET position = position + 1 WHERE id = ?1")
|
||||
.bind(last)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
position
|
||||
}
|
||||
Some((_, position)) => position + 1,
|
||||
None => 0,
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO board_lists (space_id, name, position, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
@@ -278,8 +327,9 @@ pub async fn create_list(
|
||||
.bind(name)
|
||||
.bind(position)
|
||||
.bind(crate::now())
|
||||
.execute(&state.db)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
state.backup.signal();
|
||||
load(&state, space_id, space_name).await.map(Json)
|
||||
@@ -462,9 +512,14 @@ pub async fn move_card(
|
||||
|
||||
let mut tx = state.db.begin().await?;
|
||||
|
||||
sqlx::query("UPDATE cards SET list_id = ?1, updated_at = ?2 WHERE id = ?3")
|
||||
let now = crate::now();
|
||||
let last = last_list(&mut *tx, to).await?.map_or(input.list_id, |(id, _)| id);
|
||||
let done_at = done_after_move(current.list_id, input.list_id, last, current.done_at, now);
|
||||
|
||||
sqlx::query("UPDATE cards SET list_id = ?1, done_at = ?2, updated_at = ?3 WHERE id = ?4")
|
||||
.bind(input.list_id)
|
||||
.bind(crate::now())
|
||||
.bind(done_at)
|
||||
.bind(now)
|
||||
.bind(card_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -525,7 +580,19 @@ async fn card(db: &SqlitePool, id: i64) -> AppResult<Card> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::reordered;
|
||||
use super::{done_after_move, reordered};
|
||||
|
||||
#[test]
|
||||
fn arriving_in_the_last_list_finishes_a_card_and_leaving_reopens_it() {
|
||||
let (inbox, doing, done) = (1, 2, 3);
|
||||
assert_eq!(done_after_move(doing, done, done, None, 100), Some(100));
|
||||
// Finished earlier and shuffled back in: the first stamp is the true one.
|
||||
assert_eq!(done_after_move(inbox, done, done, Some(50), 100), Some(50));
|
||||
assert_eq!(done_after_move(done, doing, done, Some(50), 100), None);
|
||||
// Reordering inside a list is not a change of state.
|
||||
assert_eq!(done_after_move(done, done, done, Some(50), 100), Some(50));
|
||||
assert_eq!(done_after_move(inbox, doing, done, None, 100), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_card_lands_where_it_was_asked_to() {
|
||||
|
||||
Reference in new issue
Block a user