Skip to content

Commit 884aad7

Browse files
committed
❇️ [refactor][frontend] Skip unchanged writes on event submit
1 parent 4682166 commit 884aad7

4 files changed

Lines changed: 110 additions & 42 deletions

File tree

frontend/src/lib/event-entries.ts

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
type EventEntryRead,
23
createEventEntries,
34
deleteEventEntries,
45
updateEventEntries
@@ -11,12 +12,24 @@ interface SyncEventEntriesOptions {
1112
eventId: number
1213
// Rows as submitted by the event form, in display order.
1314
entries: EventEntryPayload[]
14-
// Ids the event carried before this submit, so dropped rows can be deleted.
15-
previousIds: number[]
15+
// Entries the event carried before this submit, so dropped rows can be
16+
// deleted and untouched rows can be left out of the patch.
17+
previousEntries: EventEntryRead[]
18+
}
19+
20+
function isUnchanged(entry: EventEntryPayload, previous: EventEntryRead) {
21+
return (
22+
entry.category_id === previous.category_id &&
23+
entry.amount === previous.amount &&
24+
entry.quantity === previous.quantity &&
25+
entry.currency_code === previous.currency_code &&
26+
entry.description === (previous.description ?? null) &&
27+
entry.index === previous.index
28+
)
1629
}
1730

1831
// Reconcile an event's entries with the rows the form submitted: dropped rows
19-
// are deleted, existing rows are patched, and new rows are created.
32+
// are deleted, changed rows are patched, and new rows are created.
2033
//
2134
// The three calls must stay in this order. Indexes are unique per event, and
2235
// the API only parks colliding indexes out of the way for rows inside the batch
@@ -27,12 +40,14 @@ export async function syncEventEntries({
2740
client,
2841
eventId,
2942
entries,
30-
previousIds
43+
previousEntries
3144
}: SyncEventEntriesOptions) {
3245
const nextIds = new Set(
3346
entries.map(({ id }) => id).filter((id) => id != null)
3447
)
35-
const removedIds = previousIds.filter((id) => !nextIds.has(id))
48+
const removedIds = previousEntries
49+
.map(({ id }) => id)
50+
.filter((id) => !nextIds.has(id))
3651
if (removedIds.length > 0) {
3752
await deleteEventEntries({
3853
client,
@@ -41,10 +56,22 @@ export async function syncEventEntries({
4156
})
4257
}
4358

44-
// Every surviving row is sent, not just the edited ones, so the whole index
45-
// range the patch writes to is inside the batch and can be parked.
46-
const updates = entries
47-
.filter(({ id }) => id != null)
59+
const previousById = new Map(
60+
previousEntries.map((entry) => [entry.id, entry])
61+
)
62+
const surviving = entries.filter(({ id }) => id != null)
63+
// A reorder moves rows into indexes other rows still hold, and only the rows
64+
// in the batch get parked, so a reorder has to send all of them. Absent one,
65+
// no row is moving and untouched rows can be left out.
66+
const reordered = surviving.some(
67+
(entry) => previousById.get(entry.id as number)?.index !== entry.index
68+
)
69+
const updates = surviving
70+
.filter((entry) => {
71+
if (reordered) return true
72+
const previous = previousById.get(entry.id as number)
73+
return !previous || !isUnchanged(entry, previous)
74+
})
4875
.map((entry) => ({ ...entry, id: entry.id as number, event_id: eventId }))
4976
if (updates.length > 0) {
5077
await updateEventEntries({ client, body: updates, throwOnError: true })

frontend/src/lib/events.ts

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { createTransaction, updateTransactions } from '@/lib/client'
1+
import {
2+
type EventCreate,
3+
type EventReadDetailed,
4+
type TransactionRead,
5+
createTransaction,
6+
updateTransactions
7+
} from '@/lib/client'
28
import { type Client } from '@/lib/client/client'
39
import { type TransactionPayload } from '@/lib/types'
410

@@ -7,41 +13,68 @@ interface SyncEventTransactionsOptions {
713
eventId: number
814
// Rows as submitted by the event form, in display order.
915
transactions: TransactionPayload[]
10-
// Ids the event carried before this submit, so dropped rows can be unlinked.
11-
previousIds: number[]
16+
// Rows the event carried before this submit, so dropped rows can be unlinked
17+
// and an untouched set can skip the patch.
18+
previousTransactions: TransactionRead[]
1219
// Applied to newly created transactions, so they land on the event's instant.
1320
createdAt: string
1421
}
1522

23+
// Whether the submitted body differs from the event being edited, so a submit
24+
// that only touched transactions or entries can skip patching the event.
25+
export function hasEventChanges(body: EventCreate, event: EventReadDetailed) {
26+
return (
27+
body.type !== event.type ||
28+
body.timestamp !== event.timestamp ||
29+
body.timezone !== event.timezone ||
30+
(body.description ?? null) !== (event.description ?? null)
31+
)
32+
}
33+
1634
// Reconcile an event's transactions with the rows the form submitted: existing
1735
// rows are patched onto the event, dropped rows are unlinked (the API has no
1836
// delete), and new rows are created.
1937
export async function syncEventTransactions({
2038
client,
2139
eventId,
2240
transactions,
23-
previousIds,
41+
previousTransactions,
2442
createdAt
2543
}: SyncEventTransactionsOptions) {
2644
const nextIds = new Set(
2745
transactions.map(({ id }) => id).filter((id) => id != null)
2846
)
29-
const updates = [
30-
...transactions
31-
.filter(({ id }) => id != null)
32-
.map(({ id, account_id, amount, index }) => ({
33-
id: id as number,
34-
account_id,
35-
amount,
36-
index,
37-
event_id: eventId
38-
})),
39-
...previousIds
40-
.filter((id) => !nextIds.has(id))
41-
.map((id) => ({ id, event_id: null }))
42-
]
43-
if (updates.length > 0) {
44-
await updateTransactions({ client, body: updates, throwOnError: true })
47+
const previousById = new Map(previousTransactions.map((row) => [row.id, row]))
48+
const existing = transactions.filter(({ id }) => id != null)
49+
const unlinked = previousTransactions.filter(({ id }) => !nextIds.has(id))
50+
51+
// All or nothing: a reorder can move a row into an index another row still
52+
// holds, and only the rows in the batch get parked out of the way. So the
53+
// patch either carries every row or is skipped entirely.
54+
const changed = existing.some((transaction) => {
55+
const previous = previousById.get(transaction.id as number)
56+
return (
57+
!previous ||
58+
previous.account_id !== transaction.account_id ||
59+
previous.amount !== transaction.amount ||
60+
(previous.index ?? null) !== transaction.index
61+
)
62+
})
63+
if (changed || unlinked.length > 0) {
64+
await updateTransactions({
65+
client,
66+
body: [
67+
...existing.map(({ id, account_id, amount, index }) => ({
68+
id: id as number,
69+
account_id,
70+
amount,
71+
index,
72+
event_id: eventId
73+
})),
74+
...unlinked.map(({ id }) => ({ id, event_id: null }))
75+
],
76+
throwOnError: true
77+
})
4578
}
4679

4780
// Created one at a time: the API has no batch create, and the account balance

frontend/src/routes/_auth/account/$id/transaction.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,15 +119,15 @@ function AccountTransactionPage() {
119119
client,
120120
eventId: event.id,
121121
transactions,
122-
previousIds: [],
122+
previousTransactions: [],
123123
createdAt: body.timestamp
124124
})
125125

126126
await syncEventEntries({
127127
client,
128128
eventId: event.id,
129129
entries,
130-
previousIds: []
130+
previousEntries: []
131131
})
132132

133133
return event

frontend/src/routes/_auth/index.tsx

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
readTransactionsQueryKey
2323
} from '@/lib/client/@tanstack/react-query.gen'
2424
import { syncEventEntries } from '@/lib/event-entries'
25-
import { syncEventTransactions } from '@/lib/events'
25+
import { hasEventChanges, syncEventTransactions } from '@/lib/events'
2626
import { type EventEntryPayload, type TransactionPayload } from '@/lib/types'
2727
import { parseLocalDate } from '@/lib/utils'
2828

@@ -75,28 +75,36 @@ function HomePage() {
7575
transactions: TransactionPayload[]
7676
entries: EventEntryPayload[]
7777
}) => {
78-
const { data: event } = editingEvent
79-
? await updateEvent({
80-
client,
81-
path: { event_id: editingEvent.id },
82-
body,
83-
throwOnError: true
84-
})
85-
: await createEvent({ client, body, throwOnError: true })
78+
let event: { id: number }
79+
if (!editingEvent) {
80+
const { data } = await createEvent({ client, body, throwOnError: true })
81+
event = data
82+
} else if (hasEventChanges(body, editingEvent)) {
83+
const { data } = await updateEvent({
84+
client,
85+
path: { event_id: editingEvent.id },
86+
body,
87+
throwOnError: true
88+
})
89+
event = data
90+
} else {
91+
// Only the transactions or entries changed, so the event needs no patch.
92+
event = editingEvent
93+
}
8694

8795
await syncEventTransactions({
8896
client,
8997
eventId: event.id,
9098
transactions,
91-
previousIds: editingEvent?.transactions.map((t) => t.id) ?? [],
99+
previousTransactions: editingEvent?.transactions ?? [],
92100
createdAt: body.timestamp
93101
})
94102

95103
await syncEventEntries({
96104
client,
97105
eventId: event.id,
98106
entries,
99-
previousIds: editingEvent?.entries.map((e) => e.id) ?? []
107+
previousEntries: editingEvent?.entries ?? []
100108
})
101109

102110
return event

0 commit comments

Comments
 (0)