style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
@@ -15,14 +15,14 @@
|
||||
* Fire-and-forget: sync correctness does not depend on push success.
|
||||
*/
|
||||
|
||||
import { eq, ne } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { users, pushSubscriptions } from '../db/schema.js'
|
||||
import { dispatchPush } from './pushDispatcher.js'
|
||||
import { eq, ne } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { users, pushSubscriptions } from '../db/schema.js';
|
||||
import { dispatchPush } from './pushDispatcher.js';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type EventChangeOperation = 'create' | 'update' | 'delete'
|
||||
export type EventChangeOperation = 'create' | 'update' | 'delete';
|
||||
|
||||
/**
|
||||
* Meaningful fields whose change triggers a push notification (D-04).
|
||||
@@ -34,21 +34,21 @@ export const MEANINGFUL_FIELDS = new Set([
|
||||
'allDay',
|
||||
'title',
|
||||
'location',
|
||||
])
|
||||
]);
|
||||
|
||||
/**
|
||||
* Payload describing a detected calendar event change.
|
||||
* Produced by syncCalendar and consumed by poller + outboxWorker.
|
||||
*/
|
||||
export interface EventChange {
|
||||
uid: string
|
||||
title: string | null
|
||||
operation: EventChangeOperation
|
||||
uid: string;
|
||||
title: string | null;
|
||||
operation: EventChangeOperation;
|
||||
/** For 'update': which fields changed. Omit for 'create' and 'delete'. */
|
||||
changedFields?: string[]
|
||||
changedFields?: string[];
|
||||
/** UTC timestamp of the event start (for notification copy). */
|
||||
dtstartUtc?: Date | null
|
||||
allDay?: boolean
|
||||
dtstartUtc?: Date | null;
|
||||
allDay?: boolean;
|
||||
}
|
||||
|
||||
// ── Core logic ───────────────────────────────────────────────────────────────
|
||||
@@ -63,11 +63,11 @@ export interface EventChange {
|
||||
*/
|
||||
export function isMeaningfulChange(change: EventChange): boolean {
|
||||
if (change.operation === 'create' || change.operation === 'delete') {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
// update: require at least one meaningful field
|
||||
const fields = change.changedFields ?? []
|
||||
return fields.some((f) => MEANINGFUL_FIELDS.has(f))
|
||||
const fields = change.changedFields ?? [];
|
||||
return fields.some((f) => MEANINGFUL_FIELDS.has(f));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,32 +81,32 @@ function buildCopy(
|
||||
change: EventChange,
|
||||
actorName: string,
|
||||
): { notifTitle: string; notifBody: string; navigate: string } {
|
||||
const eventTitle = change.title ?? change.uid
|
||||
const eventTitle = change.title ?? change.uid;
|
||||
|
||||
let notifTitle: string
|
||||
let notifBody: string
|
||||
let notifTitle: string;
|
||||
let notifBody: string;
|
||||
|
||||
// D-02: event notifications show specifics — actor + title.
|
||||
// D-03: name the actor in every change notification.
|
||||
if (change.operation === 'create') {
|
||||
notifTitle = `${actorName} added an event`
|
||||
notifBody = eventTitle
|
||||
notifTitle = `${actorName} added an event`;
|
||||
notifBody = eventTitle;
|
||||
} else if (change.operation === 'delete') {
|
||||
notifTitle = `${actorName} removed an event`
|
||||
notifBody = eventTitle
|
||||
notifTitle = `${actorName} removed an event`;
|
||||
notifBody = eventTitle;
|
||||
} else {
|
||||
// update
|
||||
notifTitle = `${actorName} updated an event`
|
||||
notifBody = eventTitle
|
||||
notifTitle = `${actorName} updated an event`;
|
||||
notifBody = eventTitle;
|
||||
}
|
||||
|
||||
// Navigate: /calendar?event=uid for create/update; /calendar for delete
|
||||
const navigate =
|
||||
change.operation === 'delete'
|
||||
? '/calendar'
|
||||
: `/calendar?event=${encodeURIComponent(change.uid)}`
|
||||
: `/calendar?event=${encodeURIComponent(change.uid)}`;
|
||||
|
||||
return { notifTitle, notifBody, navigate }
|
||||
return { notifTitle, notifBody, navigate };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,13 +119,10 @@ function buildCopy(
|
||||
*
|
||||
* Fire-and-forget: resolves after dispatching without awaiting push ACKs.
|
||||
*/
|
||||
export async function dispatchEventChange(
|
||||
change: EventChange,
|
||||
actorUserId: number,
|
||||
): Promise<void> {
|
||||
export async function dispatchEventChange(change: EventChange, actorUserId: number): Promise<void> {
|
||||
// D-04: skip description-only edits
|
||||
if (!isMeaningfulChange(change)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// IN-01: resolve actor display name for D-02/D-03 notification copy.
|
||||
@@ -139,22 +136,19 @@ export async function dispatchEventChange(
|
||||
// D-13: query push_subscriptions from MariaDB only
|
||||
// D-03: ne() filter excludes the actor at DB level; application-level filter
|
||||
// below provides defence-in-depth (also makes the mock-based tests deterministic).
|
||||
db
|
||||
.select()
|
||||
.from(pushSubscriptions)
|
||||
.where(ne(pushSubscriptions.userId, actorUserId)),
|
||||
])
|
||||
db.select().from(pushSubscriptions).where(ne(pushSubscriptions.userId, actorUserId)),
|
||||
]);
|
||||
|
||||
const actorName: string = actorRows[0]?.displayName ?? 'A family member'
|
||||
const actorName: string = actorRows[0]?.displayName ?? 'A family member';
|
||||
|
||||
// D-03: additional application-level actor exclusion (defence-in-depth)
|
||||
const subs = allSubs.filter((s) => s.userId !== actorUserId)
|
||||
const subs = allSubs.filter((s) => s.userId !== actorUserId);
|
||||
|
||||
if (subs.length === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const { notifTitle, notifBody, navigate } = buildCopy(change, actorName)
|
||||
const { notifTitle, notifBody, navigate } = buildCopy(change, actorName);
|
||||
|
||||
// Fan out to all non-actor subscriptions (D-03 already enforced by ne() filter)
|
||||
for (const sub of subs) {
|
||||
@@ -164,12 +158,12 @@ export async function dispatchEventChange(
|
||||
body: notifBody,
|
||||
tag: `event-change-${change.uid}`,
|
||||
navigate,
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[eventChangeDispatcher] Error dispatching for uid=${change.uid} sub.id=${sub.id}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,27 +17,24 @@
|
||||
* Source: RESEARCH.md Finding 3 verbatim pattern.
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { lists, listShares } from '../db/schema.js'
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { lists, listShares } from '../db/schema.js';
|
||||
|
||||
/**
|
||||
* Returns all list IDs accessible to userId:
|
||||
* owned lists UNION lists shared to this user, deduplicated.
|
||||
*/
|
||||
export async function getAccessibleListIds(userId: number): Promise<number[]> {
|
||||
const owned = await db.select({ id: lists.id }).from(lists).where(eq(lists.ownerId, userId))
|
||||
const owned = await db.select({ id: lists.id }).from(lists).where(eq(lists.ownerId, userId));
|
||||
|
||||
const shared = await db
|
||||
.select({ listId: listShares.listId })
|
||||
.from(listShares)
|
||||
.where(eq(listShares.userId, userId))
|
||||
.where(eq(listShares.userId, userId));
|
||||
|
||||
const all = [
|
||||
...owned.map((r) => r.id),
|
||||
...shared.map((r) => r.listId),
|
||||
]
|
||||
const all = [...owned.map((r) => r.id), ...shared.map((r) => r.listId)];
|
||||
|
||||
// Deduplicate (handles the degenerate case where a list is both owned and shared)
|
||||
return [...new Set(all)]
|
||||
return [...new Set(all)];
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
* - coalesceListPush handles burst collapsing; this module owns audience + copy.
|
||||
*/
|
||||
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { users, lists, listShares, pushSubscriptions } from '../db/schema.js'
|
||||
import { coalesceListPush } from './pushCoalescer.js'
|
||||
import { dispatchPush } from './pushDispatcher.js'
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { users, lists, listShares, pushSubscriptions } from '../db/schema.js';
|
||||
import { coalesceListPush } from './pushCoalescer.js';
|
||||
import { dispatchPush } from './pushDispatcher.js';
|
||||
|
||||
/**
|
||||
* Notify all accessible, non-actor subscribers that a list changed.
|
||||
@@ -36,83 +36,82 @@ import { dispatchPush } from './pushDispatcher.js'
|
||||
* for fast fake-timer or real-timer test execution.
|
||||
*/
|
||||
export function notifyListChange(listId: number, actorId: number, windowMs?: number): void {
|
||||
coalesceListPush(listId, actorId, async (coalListId, coalActorId, count) => {
|
||||
try {
|
||||
await sendListChangePush(coalListId, coalActorId, count)
|
||||
} catch (err: unknown) {
|
||||
console.error(
|
||||
`[listChangeDispatcher] unhandled error for list ${coalListId}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
}
|
||||
}, windowMs)
|
||||
coalesceListPush(
|
||||
listId,
|
||||
actorId,
|
||||
async (coalListId, coalActorId, count) => {
|
||||
try {
|
||||
await sendListChangePush(coalListId, coalActorId, count);
|
||||
} catch (err: unknown) {
|
||||
console.error(
|
||||
`[listChangeDispatcher] unhandled error for list ${coalListId}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
},
|
||||
windowMs,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner dispatch: resolves actor name + list name, builds the audience,
|
||||
* and sends a push to every accessible non-actor subscriber.
|
||||
*/
|
||||
async function sendListChangePush(
|
||||
listId: number,
|
||||
actorId: number,
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
async function sendListChangePush(listId: number, actorId: number, count: number): Promise<void> {
|
||||
// Resolve actor display name and list name in parallel
|
||||
const [actorRow, listRow] = await Promise.all([
|
||||
db.select({ displayName: users.displayName }).from(users).where(eq(users.id, actorId)).limit(1),
|
||||
db.select({ name: lists.name }).from(lists).where(eq(lists.id, listId)).limit(1),
|
||||
])
|
||||
]);
|
||||
|
||||
if (!listRow[0]) {
|
||||
// List deleted between mutation and coalesce fire — no-op
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const actorName: string = actorRow[0]?.displayName ?? 'Someone'
|
||||
const listName: string = listRow[0].name
|
||||
const actorName: string = actorRow[0]?.displayName ?? 'Someone';
|
||||
const listName: string = listRow[0].name;
|
||||
|
||||
// Build audience: list owner ∪ list_shares members, MINUS the actor (D-03)
|
||||
const [ownerRows, shareRows] = await Promise.all([
|
||||
db.select({ ownerId: lists.ownerId }).from(lists).where(eq(lists.id, listId)).limit(1),
|
||||
db.select({ userId: listShares.userId }).from(listShares).where(eq(listShares.listId, listId)),
|
||||
])
|
||||
]);
|
||||
|
||||
if (!ownerRows[0]) {
|
||||
// List gone — no-op
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerId = ownerRows[0].ownerId
|
||||
const shareUserIds = shareRows.map((r) => r.userId)
|
||||
const ownerId = ownerRows[0].ownerId;
|
||||
const shareUserIds = shareRows.map((r) => r.userId);
|
||||
|
||||
// Union of owner + sharees; deduplicate; exclude actor (D-03)
|
||||
const audienceIds = [
|
||||
...new Set([ownerId, ...shareUserIds]),
|
||||
].filter((uid) => uid !== actorId)
|
||||
const audienceIds = [...new Set([ownerId, ...shareUserIds])].filter((uid) => uid !== actorId);
|
||||
|
||||
if (audienceIds.length === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Load push subscriptions for all audience members
|
||||
const subs = await db
|
||||
.select()
|
||||
.from(pushSubscriptions)
|
||||
.where(inArray(pushSubscriptions.userId, audienceIds))
|
||||
.where(inArray(pushSubscriptions.userId, audienceIds));
|
||||
|
||||
if (subs.length === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// D-02 generic copy: "{Actor} made {N} changes to {ListName}"
|
||||
// No item text — keeps the lock screen clean.
|
||||
const body = `${actorName} made ${count} ${count === 1 ? 'change' : 'changes'} to ${listName}`
|
||||
const body = `${actorName} made ${count} ${count === 1 ? 'change' : 'changes'} to ${listName}`;
|
||||
const notification = {
|
||||
title: listName,
|
||||
body,
|
||||
tag: `list-change:${listId}`,
|
||||
navigate: `/lists/${listId}`,
|
||||
}
|
||||
};
|
||||
|
||||
// Fan out to each subscription; one failure must not abort the rest (T-05-04)
|
||||
for (const sub of subs) {
|
||||
@@ -120,7 +119,7 @@ async function sendListChangePush(
|
||||
console.error(
|
||||
`[listChangeDispatcher] dispatchPush failed for sub ${sub.id}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,29 +15,29 @@
|
||||
* Source: RESEARCH.md Finding 1 verbatim pattern.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
// Module-level singleton — one emitter shared across all route handlers
|
||||
// in this Node.js process.
|
||||
const emitter = new EventEmitter()
|
||||
emitter.setMaxListeners(200) // 100 members × 2 devices, generous headroom (T-04-04)
|
||||
const emitter = new EventEmitter();
|
||||
emitter.setMaxListeners(200); // 100 members × 2 devices, generous headroom (T-04-04)
|
||||
|
||||
/**
|
||||
* Event type union for list change notifications.
|
||||
* All events carry the originating listId and an opaque payload.
|
||||
*/
|
||||
export type ListEvent = {
|
||||
type: 'item:added' | 'item:updated' | 'item:deleted' | 'list:updated' | 'list:deleted'
|
||||
listId: number
|
||||
payload: unknown
|
||||
}
|
||||
type: 'item:added' | 'item:updated' | 'item:deleted' | 'list:updated' | 'list:deleted';
|
||||
listId: number;
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Broadcast an event to all SSE subscribers watching this list.
|
||||
* Channel is keyed by listId — events for list A never reach subscribers of list B.
|
||||
*/
|
||||
export function publishListEvent(listId: number, event: ListEvent): void {
|
||||
emitter.emit(`list:${listId}`, event)
|
||||
emitter.emit(`list:${listId}`, event);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +48,7 @@ export function subscribeListEvents(
|
||||
listId: number,
|
||||
handler: (event: ListEvent) => void,
|
||||
): () => void {
|
||||
const channel = `list:${listId}`
|
||||
emitter.on(channel, handler)
|
||||
return () => emitter.off(channel, handler)
|
||||
const channel = `list:${listId}`;
|
||||
emitter.on(channel, handler);
|
||||
return () => emitter.off(channel, handler);
|
||||
}
|
||||
|
||||
@@ -8,16 +8,16 @@
|
||||
* Exports: coalesceListPush
|
||||
*/
|
||||
|
||||
type DispatchFn = (listId: number, actorId: number, count: number) => Promise<void>
|
||||
type DispatchFn = (listId: number, actorId: number, count: number) => Promise<void>;
|
||||
|
||||
type PendingEntry = {
|
||||
count: number
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
count: number;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
// Module-level singleton — keyed by `${listId}:${actorId}`.
|
||||
// Entries are self-deleting: deleted when the timer fires.
|
||||
const pending = new Map<string, PendingEntry>()
|
||||
const pending = new Map<string, PendingEntry>();
|
||||
|
||||
/**
|
||||
* Coalesce list-change push notifications for a single (list, actor) pair.
|
||||
@@ -36,35 +36,30 @@ export function coalesceListPush(
|
||||
dispatch: DispatchFn,
|
||||
windowMs = 45_000,
|
||||
): void {
|
||||
const key = `${listId}:${actorId}`
|
||||
const existing = pending.get(key)
|
||||
const key = `${listId}:${actorId}`;
|
||||
const existing = pending.get(key);
|
||||
|
||||
if (existing) {
|
||||
// Extend the window on every new call within the burst (sliding debounce).
|
||||
clearTimeout(existing.timer)
|
||||
existing.count++
|
||||
existing.timer = setTimeout(() => fire(key, listId, actorId, dispatch), windowMs)
|
||||
clearTimeout(existing.timer);
|
||||
existing.count++;
|
||||
existing.timer = setTimeout(() => fire(key, listId, actorId, dispatch), windowMs);
|
||||
} else {
|
||||
// First call in a new burst — start a fresh entry.
|
||||
const timer = setTimeout(() => fire(key, listId, actorId, dispatch), windowMs)
|
||||
pending.set(key, { count: 1, timer })
|
||||
const timer = setTimeout(() => fire(key, listId, actorId, dispatch), windowMs);
|
||||
pending.set(key, { count: 1, timer });
|
||||
}
|
||||
}
|
||||
|
||||
function fire(
|
||||
key: string,
|
||||
listId: number,
|
||||
actorId: number,
|
||||
dispatch: DispatchFn,
|
||||
): void {
|
||||
const entry = pending.get(key)
|
||||
if (!entry) return
|
||||
const count = entry.count
|
||||
pending.delete(key)
|
||||
function fire(key: string, listId: number, actorId: number, dispatch: DispatchFn): void {
|
||||
const entry = pending.get(key);
|
||||
if (!entry) return;
|
||||
const count = entry.count;
|
||||
pending.delete(key);
|
||||
dispatch(listId, actorId, count).catch((err: unknown) => {
|
||||
console.error(
|
||||
`[pushCoalescer] dispatch failed for list ${listId}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,31 +16,31 @@
|
||||
* - dispatchPush resolves (never throws) so fan-out loops continue after failures.
|
||||
*/
|
||||
|
||||
import webpush from 'web-push'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { pushSubscriptions } from '../db/schema.js'
|
||||
import webpush from 'web-push';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { pushSubscriptions } from '../db/schema.js';
|
||||
|
||||
/**
|
||||
* Push subscription row shape (subset of schema.pushSubscriptions used by dispatcher).
|
||||
*/
|
||||
export type PushSubscription = {
|
||||
id: number
|
||||
userId: number
|
||||
endpoint: string
|
||||
p256dh: string
|
||||
auth: string
|
||||
}
|
||||
id: number;
|
||||
userId: number;
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Notification content passed to dispatchPush and used to build the push body.
|
||||
*/
|
||||
export type NotificationPayload = {
|
||||
title: string
|
||||
body?: string
|
||||
tag?: string
|
||||
navigate?: string
|
||||
}
|
||||
title: string;
|
||||
body?: string;
|
||||
tag?: string;
|
||||
navigate?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the dual-format push payload body string.
|
||||
@@ -52,7 +52,7 @@ export type NotificationPayload = {
|
||||
* The service worker reads whichever format the browser understands.
|
||||
*/
|
||||
export function buildPushBody(notification: NotificationPayload): string {
|
||||
const { title, body = '', tag, navigate } = notification
|
||||
const { title, body = '', tag, navigate } = notification;
|
||||
|
||||
return JSON.stringify({
|
||||
// iOS 18.4+ declarative web push format (WebKit blog 2025-04-14)
|
||||
@@ -69,7 +69,7 @@ export function buildPushBody(notification: NotificationPayload): string {
|
||||
data: {
|
||||
url: navigate,
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,26 +96,26 @@ export async function dispatchPush(
|
||||
p256dh: sub.p256dh,
|
||||
auth: sub.auth,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const body = buildPushBody(notification)
|
||||
const body = buildPushBody(notification);
|
||||
|
||||
try {
|
||||
await webpush.sendNotification(webPushSub, body, {
|
||||
TTL: 300,
|
||||
urgency: 'normal',
|
||||
})
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const statusCode = (err as { statusCode?: number }).statusCode
|
||||
const statusCode = (err as { statusCode?: number }).statusCode;
|
||||
|
||||
if (statusCode === 410 || statusCode === 404) {
|
||||
// Dead subscription — prune from DB (D-11)
|
||||
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, sub.id))
|
||||
return
|
||||
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, sub.id));
|
||||
return;
|
||||
}
|
||||
|
||||
// Transient error — log and continue; do NOT delete subscription
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error('[pushDispatcher] sendNotification failed:', statusCode, message)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error('[pushDispatcher] sendNotification failed:', statusCode, message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* Both are pure functions — no DB access, no side effects.
|
||||
*/
|
||||
|
||||
import { generateKeyBetween } from 'fractional-indexing'
|
||||
import { generateKeyBetween } from 'fractional-indexing';
|
||||
|
||||
/**
|
||||
* Generate a rank suitable for appending an item AFTER the last active item.
|
||||
@@ -23,7 +23,7 @@ import { generateKeyBetween } from 'fractional-indexing'
|
||||
* An empty list gets "a0" (generateKeyBetween(null, null)).
|
||||
*/
|
||||
export function rankForAppend(lastRank: string | null): string {
|
||||
return generateKeyBetween(lastRank, null)
|
||||
return generateKeyBetween(lastRank, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,5 +38,5 @@ export function rankForAppend(lastRank: string | null): string {
|
||||
* @returns a rank string that sorts between `prev` and `next` when ordered ASC.
|
||||
*/
|
||||
export function rankBetween(prev: string | null, next: string | null): string {
|
||||
return generateKeyBetween(prev, next)
|
||||
return generateKeyBetween(prev, next);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user