Rail and drawer: only the 5 tags most recently used or viewed (#5931)
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 14s
Android / Core and FFI clippy and tests (push) Successful in 1m9s
CI & Build / integration (push) Successful in 2m10s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m25s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m20s
Android / Kotlin + Rust (APK) (push) Failing after 6m3s
Android / Build the server image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m5s
Desktop (Tauri) / Update manifest (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 14s
Android / Core and FFI clippy and tests (push) Successful in 1m9s
CI & Build / integration (push) Successful in 2m10s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m25s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m20s
Android / Kotlin + Rust (APK) (push) Failing after 6m3s
Android / Build the server image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m5s
Desktop (Tauri) / Update manifest (push) Successful in 4s
A long tag list pushed the drawer's Archive, Trash and Sync off screen on Android, where Tags still sat above them (#5915 only reordered the web rail). Tags now come last on Android too, and both clients list the five tags most recently used or viewed, by name, with "All tags (N)" opening the rest in place. Used: the newest untrashed note carrying the tag. GET /api/labels and the core's list_labels return it as last_used, and each client also folds in the notes it holds, so a tag applied a moment ago counts before the next listing. Viewed: opening the tag's lens, remembered on the device (per user on the web). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* When each tag's lens was last opened on this phone, for the drawer's recent tags
|
||||
* (#5931). Opening a tag counts as using it, alongside the notes that carry it.
|
||||
*
|
||||
* Kept on the device rather than synced: it is a convenience about where this
|
||||
* person's thumb goes on this screen, and losing it costs one tap.
|
||||
*/
|
||||
class TagViews(
|
||||
context: Context,
|
||||
) {
|
||||
// applicationContext, as in SyncSettings: this must not hold an Activity.
|
||||
private val prefs =
|
||||
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
|
||||
fun all(): Map<String, Long> = prefs.all.mapNotNull { (id, at) -> (at as? Long)?.let { id to it } }.toMap()
|
||||
|
||||
/** Records a view now and returns every view, this one included. */
|
||||
fun record(tagId: String): Map<String, Long> {
|
||||
val now = System.currentTimeMillis()
|
||||
prefs.edit().putLong(tagId, now).apply()
|
||||
return all() + (tagId to now)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FILE = "inkwell-tag-views"
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -68,11 +69,13 @@ import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.fabledsword.inkwell.R
|
||||
import com.fabledsword.inkwell.TagViews
|
||||
import com.fabledsword.inkwell.core.Label
|
||||
import com.fabledsword.inkwell.core.Note
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -96,6 +99,15 @@ fun BoardScreen(
|
||||
) {
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Opening a tag's lens counts as using it, however it was opened: from the
|
||||
// drawer, or a tag on a card (#5931).
|
||||
val context = LocalContext.current
|
||||
val tagViews = remember(context) { TagViews(context) }
|
||||
var views by remember { mutableStateOf(tagViews.all()) }
|
||||
LaunchedEffect(state.destination) {
|
||||
(state.destination as? Destination.WithLabel)?.let { views = tagViews.record(it.id) }
|
||||
}
|
||||
val snackbars = remember { SnackbarHostState() }
|
||||
|
||||
// Held HERE rather than on the card. A card lives in a lazy grid and is disposed
|
||||
@@ -143,6 +155,7 @@ fun BoardScreen(
|
||||
NavigationDrawer(
|
||||
current = state.destination,
|
||||
labels = state.labels,
|
||||
recency = tagRecency(state.labels, state.notes, views),
|
||||
syncSummary = sync.summary,
|
||||
onOpen = {
|
||||
onOpen(it)
|
||||
@@ -401,11 +414,16 @@ private fun SearchBar(
|
||||
private fun NavigationDrawer(
|
||||
current: Destination,
|
||||
labels: List<Label>,
|
||||
recency: Map<String, Long>,
|
||||
syncSummary: String?,
|
||||
onOpen: (Destination) -> Unit,
|
||||
onOpenSync: () -> Unit,
|
||||
onManageTags: () -> Unit,
|
||||
) {
|
||||
// Every tag, once asked for; until then the few in use (#5931).
|
||||
var allTags by rememberSaveable { mutableStateOf(false) }
|
||||
val shown = if (allTags) labels else recentTags(labels, { recency[it.id] ?: 0L }, { it.name })
|
||||
|
||||
ModalDrawerSheet {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
@@ -418,6 +436,27 @@ private fun NavigationDrawer(
|
||||
DrawerRow(destination, current, onOpen)
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
|
||||
DrawerRow(destination, current, onOpen)
|
||||
}
|
||||
|
||||
// Sync sits below the divider with the destinations rather than behind
|
||||
// a settings gear: it is not a preference, it is where you go to find
|
||||
// out whether this phone and your desktop are actually in step. The
|
||||
// subtitle answers that without opening it.
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.sync_title)) },
|
||||
badge = syncSummary?.let { { Text(it, style = MaterialTheme.typography.labelSmall) } },
|
||||
selected = false,
|
||||
onClick = onOpenSync,
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
|
||||
// Tags last, as on the web (#5915): it is the one list here that grows
|
||||
// without limit, so everything with a fixed place sits above it.
|
||||
//
|
||||
// The header renders even with no tags, unlike the rows below it: the
|
||||
// manage screen is where you go to MAKE the first one, and hiding the
|
||||
// way in until one exists would be a door that appears only once you
|
||||
@@ -445,27 +484,28 @@ private fun NavigationDrawer(
|
||||
)
|
||||
}
|
||||
}
|
||||
labels.forEach { label ->
|
||||
shown.forEach { label ->
|
||||
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
|
||||
DrawerRow(destination, current, onOpen)
|
||||
if (labels.size > RECENT_TAG_COUNT) {
|
||||
NavigationDrawerItem(
|
||||
label = {
|
||||
Text(
|
||||
text =
|
||||
if (allTags) {
|
||||
stringResource(R.string.tags_show_fewer)
|
||||
} else {
|
||||
stringResource(R.string.tags_show_all, labels.size)
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
selected = false,
|
||||
onClick = { allTags = !allTags },
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Sync sits below the divider with the destinations rather than behind
|
||||
// a settings gear: it is not a preference, it is where you go to find
|
||||
// out whether this phone and your desktop are actually in step. The
|
||||
// subtitle answers that without opening it.
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.sync_title)) },
|
||||
badge = syncSummary?.let { { Text(it, style = MaterialTheme.typography.labelSmall) } },
|
||||
selected = false,
|
||||
onClick = onOpenSync,
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import com.fabledsword.inkwell.core.Label
|
||||
import com.fabledsword.inkwell.core.Note
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
/**
|
||||
* The drawer lists the few tags in use rather than every tag (#5931), so a long
|
||||
* list never pushes Archive, Trash and Sync out of reach. Mirrors
|
||||
* `frontend/src/notes/recentTags.ts`.
|
||||
*/
|
||||
const val RECENT_TAG_COUNT = 5
|
||||
|
||||
/**
|
||||
* The [count] most recent of [tags], listed by name so a row does not move under
|
||||
* the thumb that tapped it. Every tag while there are no more than [count].
|
||||
*/
|
||||
fun <T> recentTags(
|
||||
tags: List<T>,
|
||||
latest: (T) -> Long,
|
||||
name: (T) -> String,
|
||||
count: Int = RECENT_TAG_COUNT,
|
||||
): List<T> {
|
||||
if (tags.size <= count) return tags
|
||||
return tags
|
||||
.sortedWith(compareByDescending(latest).thenBy(String.CASE_INSENSITIVE_ORDER, name))
|
||||
.take(count)
|
||||
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, name))
|
||||
}
|
||||
|
||||
/**
|
||||
* How recently each tag was used or viewed, in epoch milliseconds: the later of its
|
||||
* newest note in the store ([Label.lastUsed]), its newest note on the board now —
|
||||
* so a tag applied a moment ago counts before the labels are next listed — and the
|
||||
* last time its lens was opened here ([views]).
|
||||
*/
|
||||
fun tagRecency(
|
||||
labels: List<Label>,
|
||||
notes: List<Note>,
|
||||
views: Map<String, Long>,
|
||||
): Map<String, Long> {
|
||||
val latest = labels.associate { it.id to maxOf(epochMillis(it.lastUsed), views[it.id] ?: 0L) }.toMutableMap()
|
||||
for (note in notes) {
|
||||
val at = epochMillis(note.updatedAt)
|
||||
for (label in note.labels) latest.computeIfPresent(label.id) { _, was -> maxOf(was, at) }
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
/** 0 for a missing or unreadable stamp: such a tag simply ranks last. */
|
||||
fun epochMillis(stamp: String?): Long =
|
||||
stamp?.let { runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull() } ?: 0L
|
||||
@@ -133,6 +133,8 @@
|
||||
<!-- Tag management. The whole vocabulary is "tag" (see Scribe #2966); the
|
||||
schema still says Label, and no string here needs to know that. -->
|
||||
<string name="tags_manage">Manage tags</string>
|
||||
<string name="tags_show_all">All tags (%1$d)</string>
|
||||
<string name="tags_show_fewer">Fewer tags</string>
|
||||
<string name="tags_title">Tags</string>
|
||||
<string name="tags_back">Back</string>
|
||||
<string name="tags_new_hint">New tag</string>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
/** The same cases as `frontend/src/notes/recentTags.test.ts`. */
|
||||
class RecentTagsTest {
|
||||
private data class Tag(
|
||||
val name: String,
|
||||
val at: Long,
|
||||
)
|
||||
|
||||
private fun recent(tags: List<Tag>) = recentTags(tags, { it.at }, { it.name }).map { it.name }
|
||||
|
||||
@Test
|
||||
fun `shows every tag while there are no more than five`() {
|
||||
val tags = listOf(Tag("c", 0), Tag("a", 0), Tag("b", 0))
|
||||
assertSame(tags, recentTags(tags, { it.at }, { it.name }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps the five used most recently, listed by name`() {
|
||||
val tags = listOf("a", "b", "c", "d", "e", "f", "g").mapIndexed { i, n -> Tag(n, i.toLong()) }
|
||||
assertEquals(listOf("c", "d", "e", "f", "g"), recent(tags))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fills with tags never used, by name, when fewer than five have been`() {
|
||||
val tags = listOf(Tag("f", 10), Tag("e", 0), Tag("d", 0), Tag("c", 0), Tag("B", 0), Tag("a", 0))
|
||||
assertEquals(listOf("a", "B", "c", "d", "f"), recent(tags))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reads both stamp forms the store holds`() {
|
||||
assertEquals(1_760_000_000_000L, epochMillis("2025-10-09T08:53:20.000Z"))
|
||||
assertEquals(1_760_000_000_000L, epochMillis("2025-10-09T08:53:20+00:00"))
|
||||
assertEquals(0L, epochMillis(null))
|
||||
assertEquals(0L, epochMillis("not a time"))
|
||||
}
|
||||
}
|
||||
@@ -334,6 +334,9 @@ pub struct Label {
|
||||
/// How many notes carry it. Only populated in listings — `None` elsewhere,
|
||||
/// matching the REST single-label responses.
|
||||
pub count: Option<i64>,
|
||||
/// The newest edit among the untrashed notes carrying it, as the store writes
|
||||
/// timestamps; None when nothing does. Ranks the drawer's recent tags (#5931).
|
||||
pub last_used: Option<String>,
|
||||
}
|
||||
|
||||
impl From<core_models::Label> for Label {
|
||||
@@ -343,12 +346,14 @@ impl From<core_models::Label> for Label {
|
||||
name,
|
||||
color,
|
||||
count,
|
||||
last_used,
|
||||
} = value;
|
||||
Label {
|
||||
id,
|
||||
name,
|
||||
color,
|
||||
count,
|
||||
last_used,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,9 @@ pub struct Label {
|
||||
// preserves the count it already holds (matches the REST single-label responses).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub count: Option<i64>,
|
||||
/// The newest `updated_at` among the untrashed notes carrying it, or None when
|
||||
/// nothing does. The drawer keeps the few most recent tags in reach (#5931).
|
||||
pub last_used: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
||||
@@ -992,6 +992,8 @@ pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::
|
||||
/// A label with how many live notes carry it; `label_row` reads it.
|
||||
const LABEL_SELECT: &str = "SELECT l.id, l.name, l.color,
|
||||
(SELECT COUNT(*) FROM note_labels nl JOIN notes n ON n.id = nl.note_id
|
||||
WHERE nl.label_id = l.id AND n.trashed = 0),
|
||||
(SELECT MAX(n.updated_at) FROM note_labels nl JOIN notes n ON n.id = nl.note_id
|
||||
WHERE nl.label_id = l.id AND n.trashed = 0)
|
||||
FROM labels l";
|
||||
|
||||
@@ -1001,6 +1003,7 @@ fn label_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<Label> {
|
||||
name: r.get(1)?,
|
||||
color: r.get(2)?,
|
||||
count: Some(r.get(3)?),
|
||||
last_used: r.get(4)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1799,6 +1802,38 @@ mod tests {
|
||||
assert_eq!(list_labels(&conn).expect("labels")[0].count, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_is_as_recent_as_its_newest_untrashed_note() {
|
||||
let conn = db();
|
||||
let used = create_label(&conn, "errands").expect("label");
|
||||
create_label(&conn, "someday").expect("label");
|
||||
let old = create_label(&conn, "old").expect("label");
|
||||
let kept = note(&conn, "milk");
|
||||
let binned = note(&conn, "stale");
|
||||
set_labels(&conn, &kept.id, std::slice::from_ref(&used.id)).expect("labels");
|
||||
set_labels(&conn, &binned.id, std::slice::from_ref(&old.id)).expect("labels");
|
||||
trash(&conn, &binned.id).expect("trash");
|
||||
|
||||
let kept = get_note(&conn, &kept.id).expect("get");
|
||||
let by_name = |name: &str| {
|
||||
list_labels(&conn)
|
||||
.expect("labels")
|
||||
.into_iter()
|
||||
.find(|l| l.name == name)
|
||||
.expect("listed")
|
||||
};
|
||||
assert_eq!(
|
||||
by_name("errands").last_used.as_deref(),
|
||||
kept.updated_at.as_deref()
|
||||
);
|
||||
assert_eq!(by_name("someday").last_used, None, "carried by nothing");
|
||||
assert_eq!(
|
||||
by_name("old").last_used,
|
||||
None,
|
||||
"a trashed note does not count"
|
||||
);
|
||||
}
|
||||
|
||||
fn tombstones(conn: &Connection) -> Vec<(String, String)> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT entity, id FROM pending_deletes ORDER BY entity, id")
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter, type LocationQueryRaw } from "vue-router";
|
||||
import { useSessionStore } from "../stores/session";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
import { useLabelsStore } from "../stores/labels";
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
import { useReminderStore } from "../stores/reminders";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import BaseModal from "./BaseModal.vue";
|
||||
@@ -16,12 +17,20 @@ import ShareDialog from "./ShareDialog.vue";
|
||||
import { isDesktop } from "../desktop/bridge";
|
||||
import { useLensName } from "../composables/useLensName";
|
||||
import { labelDotClasses } from "../notes/colors";
|
||||
import {
|
||||
RECENT_TAG_COUNT,
|
||||
loadTagViews,
|
||||
recentTags,
|
||||
recordTagView,
|
||||
type TagViews,
|
||||
} from "../notes/recentTags";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const session = useSessionStore();
|
||||
const config = useConfigStore();
|
||||
const labels = useLabelsStore();
|
||||
const notes = useNotesStore();
|
||||
const reminders = useReminderStore();
|
||||
const ui = useUiStore();
|
||||
// Sync is a desktop-app concern: the web build already IS the server's UI.
|
||||
@@ -45,6 +54,23 @@ const buildLabel = computed(
|
||||
);
|
||||
|
||||
const managing = ref(false);
|
||||
|
||||
// The rail lists the few tags in use, not all of them (#5931); "All tags" opens the
|
||||
// rest in place, below everything else, so nothing is pushed out of view.
|
||||
const viewer = computed(() => session.user?.id ?? "local");
|
||||
const tagViews = ref<TagViews>({});
|
||||
const allTags = ref(false);
|
||||
watch(viewer, (id) => (tagViews.value = loadTagViews(id)), { immediate: true });
|
||||
watch(
|
||||
() => (route.name === "label" ? String(route.params.id) : null),
|
||||
(id) => {
|
||||
if (id) tagViews.value = recordTagView(viewer.value, tagViews.value, id);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const railTags = computed(() =>
|
||||
allTags.value ? labels.items : recentTags(labels.items, notes.items, tagViews.value),
|
||||
);
|
||||
const showShortcuts = ref(false);
|
||||
const paletteOpen = ref(false);
|
||||
const drawer = ref(false);
|
||||
@@ -419,7 +445,8 @@ async function signOut() {
|
||||
<ImportNotes />
|
||||
|
||||
<!-- Tags last: it is the one list here that grows without limit, so
|
||||
everything with a fixed place sits above it and stays put. -->
|
||||
everything with a fixed place sits above it and stays put. And it
|
||||
shows only the few tags in use (#5931), the rest one press away. -->
|
||||
<div class="mt-3 flex items-center justify-between px-3 pb-1">
|
||||
<span class="section-label">Tags</span>
|
||||
<button
|
||||
@@ -434,7 +461,7 @@ async function signOut() {
|
||||
</div>
|
||||
<p v-if="!labels.items.length" class="px-3 py-1 text-xs text-neutral-400">No tags yet</p>
|
||||
<RouterLink
|
||||
v-for="lb in labels.items"
|
||||
v-for="lb in railTags"
|
||||
:key="lb.id"
|
||||
:to="`/label/${lb.id}`"
|
||||
class="nav-link"
|
||||
@@ -446,6 +473,15 @@ async function signOut() {
|
||||
></span>
|
||||
<span class="truncate">{{ lb.name }}</span>
|
||||
</RouterLink>
|
||||
<button
|
||||
v-if="labels.items.length > RECENT_TAG_COUNT"
|
||||
type="button"
|
||||
class="nav-link text-xs text-neutral-500 dark:text-neutral-400"
|
||||
:aria-expanded="allTags"
|
||||
@click.stop="allTags = !allTags"
|
||||
>
|
||||
{{ allTags ? "Fewer tags" : `All tags (${labels.items.length})` }}
|
||||
</button>
|
||||
|
||||
<!-- Account, settings and sign-out, for the screens where they are NOT in
|
||||
the header. Four icons plus a search field never fit one phone-width
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { recentTags } from "./recentTags";
|
||||
|
||||
const tag = (id: string, last_used: string | null = null) => ({ id, name: id, last_used });
|
||||
const names = (tags: { name: string }[]) => tags.map((t) => t.name);
|
||||
|
||||
describe("the rail's recent tags", () => {
|
||||
it("shows every tag while there are no more than five", () => {
|
||||
const tags = ["c", "a", "b"].map((id) => tag(id));
|
||||
expect(recentTags(tags, [], {})).toBe(tags);
|
||||
});
|
||||
|
||||
it("keeps the five used most recently, listed by name", () => {
|
||||
const tags = ["a", "b", "c", "d", "e", "f", "g"].map((id, i) =>
|
||||
tag(id, `2026-10-0${i + 1}T00:00:00Z`),
|
||||
);
|
||||
expect(names(recentTags(tags, [], {}))).toEqual(["c", "d", "e", "f", "g"]);
|
||||
});
|
||||
|
||||
it("counts opening a tag as using it", () => {
|
||||
const tags = ["a", "b", "c", "d", "e", "f"].map((id) => tag(id, "2026-10-02T00:00:00Z"));
|
||||
tags[0].last_used = "2026-01-01T00:00:00Z";
|
||||
const viewed = { a: Date.parse("2026-10-10T00:00:00Z") };
|
||||
expect(names(recentTags(tags, [], viewed))).toContain("a");
|
||||
});
|
||||
|
||||
it("counts a note edited since the listing was fetched", () => {
|
||||
const tags = ["a", "b", "c", "d", "e", "f"].map((id) => tag(id, "2026-10-02T00:00:00Z"));
|
||||
tags[0].last_used = null;
|
||||
const notes = [{ labels: [{ id: "a" }], updated_at: "2026-10-12T00:00:00+00:00" }];
|
||||
expect(names(recentTags(tags, notes, {}))).toContain("a");
|
||||
});
|
||||
|
||||
it("fills with tags never used, by name, when fewer than five have been", () => {
|
||||
const tags = ["f", "e", "d", "c", "b", "a"].map((id) => tag(id));
|
||||
tags[0].last_used = "2026-10-02T00:00:00Z";
|
||||
expect(names(recentTags(tags, [], {}))).toEqual(["a", "b", "c", "d", "f"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// The rail keeps only the few tags you are using, so a long tag list never pushes
|
||||
// anything out of view (#5931). A tag is as recent as the later of:
|
||||
// - its last use: the newest note carrying it, from the listing (`last_used`), and
|
||||
// from the notes already loaded, so a tag applied a moment ago counts before the
|
||||
// listing is next fetched;
|
||||
// - its last view: opening its lens, remembered on this device for this user.
|
||||
// The few shown are listed by name, not by recency, so a row does not move under
|
||||
// the pointer when it is clicked.
|
||||
|
||||
export const RECENT_TAG_COUNT = 5;
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
last_used?: string | null;
|
||||
}
|
||||
|
||||
interface TaggedNote {
|
||||
labels: { id: string }[];
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export type TagViews = Record<string, number>;
|
||||
|
||||
function ms(when: string | null | undefined): number {
|
||||
const t = when ? Date.parse(when) : NaN;
|
||||
return Number.isNaN(t) ? 0 : t;
|
||||
}
|
||||
|
||||
export function recentTags<T extends Tag>(
|
||||
tags: T[],
|
||||
notes: TaggedNote[],
|
||||
views: TagViews,
|
||||
count = RECENT_TAG_COUNT,
|
||||
): T[] {
|
||||
if (tags.length <= count) return tags;
|
||||
const latest = new Map<string, number>();
|
||||
const bump = (id: string, t: number) => latest.set(id, Math.max(latest.get(id) ?? 0, t));
|
||||
for (const tag of tags) {
|
||||
bump(tag.id, ms(tag.last_used));
|
||||
bump(tag.id, views[tag.id] ?? 0);
|
||||
}
|
||||
for (const note of notes) {
|
||||
const t = ms(note.updated_at);
|
||||
for (const lb of note.labels) if (latest.has(lb.id)) bump(lb.id, t);
|
||||
}
|
||||
return [...tags]
|
||||
.sort((a, b) => (latest.get(b.id) ?? 0) - (latest.get(a.id) ?? 0) || a.name.localeCompare(b.name))
|
||||
.slice(0, count)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// Views live in this browser, one record per user so a shared machine keeps each
|
||||
// person's own. Storage can be missing or refuse (private windows); the rail then
|
||||
// ranks by use alone.
|
||||
const VIEWS_KEY = "inkwell.tag-views.";
|
||||
|
||||
export function loadTagViews(userId: string): TagViews {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(localStorage.getItem(VIEWS_KEY + userId) ?? "{}");
|
||||
return parsed && typeof parsed === "object" ? (parsed as TagViews) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function recordTagView(userId: string, views: TagViews, tagId: string, at = Date.now()): TagViews {
|
||||
const next = { ...views, [tagId]: at };
|
||||
try {
|
||||
localStorage.setItem(VIEWS_KEY + userId, JSON.stringify(next));
|
||||
} catch {
|
||||
// Kept for this session only.
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -9,6 +9,9 @@ export interface Label {
|
||||
color: string;
|
||||
// Number of notes carrying this label (from GET /api/labels; used in label management).
|
||||
count?: number;
|
||||
// The newest edit among the untrashed notes carrying it, listings only. Ranks the
|
||||
// rail's recent tags (#5931).
|
||||
last_used?: string | null;
|
||||
}
|
||||
|
||||
export const useLabelsStore = defineStore("labels", () => {
|
||||
|
||||
+25
-1
@@ -6,8 +6,10 @@ from sqlalchemy import func, select
|
||||
from .auth import login_required
|
||||
from .colors import normalize_color
|
||||
from .db import session_scope
|
||||
from .common import iso
|
||||
from .labeling import live, named, tombstone_label
|
||||
from .models.label import Label, NoteLabel
|
||||
from .models.note import Note
|
||||
from .responses import json_error, not_found, parse_uuid
|
||||
from .serialize import serialize_label
|
||||
|
||||
@@ -79,7 +81,29 @@ async def list_labels():
|
||||
)
|
||||
).all()
|
||||
) if labels else {}
|
||||
return jsonify({"labels": [_serialize_label(lb, int(counts.get(lb.id, 0))) for lb in labels]})
|
||||
# When each was last used: the newest edit among the notes carrying it, trash
|
||||
# aside. The rail shows the few most recent (#5931), so a tag in daily use
|
||||
# stays in reach however many others there are.
|
||||
last_used = dict(
|
||||
(
|
||||
await db.execute(
|
||||
select(NoteLabel.label_id, func.max(Note.updated_at))
|
||||
.join(Note, Note.id == NoteLabel.note_id)
|
||||
.where(
|
||||
NoteLabel.label_id.in_([lb.id for lb in labels]),
|
||||
Note.deleted_at.is_(None),
|
||||
Note.purged_at.is_(None),
|
||||
)
|
||||
.group_by(NoteLabel.label_id)
|
||||
)
|
||||
).all()
|
||||
) if labels else {}
|
||||
return jsonify({
|
||||
"labels": [
|
||||
{**_serialize_label(lb, int(counts.get(lb.id, 0))), "last_used": iso(last_used.get(lb.id))}
|
||||
for lb in labels
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@bp.post("")
|
||||
|
||||
@@ -665,6 +665,26 @@ async def _push_label(app_client, lid: str, op: str, edited_at: str, name: str =
|
||||
return (await resp.get_json())["results"][0]
|
||||
|
||||
|
||||
async def test_a_tag_listing_says_when_each_was_last_used(app_client, db):
|
||||
"""The rail keeps the most recent few (#5931): a tag is as recent as the newest
|
||||
note carrying it, and a note in the trash does not count."""
|
||||
await _signed_in(app_client, "lastused")
|
||||
used = await (await app_client.post("/api/labels", json={"name": "errands"})).get_json()
|
||||
idle = await (await app_client.post("/api/labels", json={"name": "someday"})).get_json()
|
||||
binned = await (await app_client.post("/api/labels", json={"name": "old"})).get_json()
|
||||
note = await (await app_client.post("/api/notes", json={"body": "milk"})).get_json()
|
||||
await app_client.put(f"/api/notes/{note['id']}/labels", json={"label_ids": [used["id"]]})
|
||||
gone = await (await app_client.post("/api/notes", json={"body": "stale"})).get_json()
|
||||
await app_client.put(f"/api/notes/{gone['id']}/labels", json={"label_ids": [binned["id"]]})
|
||||
assert (await app_client.post(f"/api/notes/{gone['id']}/trash")).status_code == 200
|
||||
|
||||
listing = {lb["id"]: lb for lb in (await (await app_client.get("/api/labels")).get_json())["labels"]}
|
||||
current = (await (await app_client.get(f"/api/notes/{note['id']}")).get_json())["updated_at"]
|
||||
assert listing[used["id"]]["last_used"] == current
|
||||
assert listing[idle["id"]]["last_used"] is None
|
||||
assert listing[binned["id"]]["last_used"] is None
|
||||
|
||||
|
||||
async def test_a_tag_deleted_on_the_web_reaches_devices_as_a_tombstone(app_client, db):
|
||||
await _signed_in(app_client, "webdelete")
|
||||
tag = await (await app_client.post("/api/labels", json={"name": "grocery"})).get_json()
|
||||
|
||||
Reference in New Issue
Block a user