Added
-
Notes gain category management, a category picker and an AND filter. Manage personal
categories and, when permitted, household categories on the Notes board, then select several
categories to show notes that belong to every selection. Household categories remain assignable
for members who cannot manage them. The dashboard Notes widget supports the same filter. Category
badges stay on one line; a +N control reveals and correctly announces the remaining categories on
hover, focus or tap, and category icons remain intact after switching between reading and editing. -
The holiday country list now includes the United States, Canada, the United Kingdom, Australia
and New Zealand (#965). OpenHolidays, the free API behind Settings → Calendar's holiday sync,
doesn't cover these five - their public holidays are computed locally instead (fixed dates,
n-th-weekday rules, Easter offsets, and each country's own documented weekend-observance rule),
the same approach already used for Brazil. The United Kingdom is offered as three regions -
England & Wales, Scotland, and Northern Ireland - since their holidays genuinely differ, not just
their names. None of the five has school-holiday data available, so the school-holiday toggle is
disabled with an explanation when one of them is selected, rather than silently syncing nothing.
For any other country not covered by OpenHolidays or this local list, an ordinary ICS calendar
subscription (Settings → Personal → Calendar subscriptions) can still bring in its public
holidays - now mentioned there directly. -
A new event goes to the calendar of the person it is assigned to (#1060). A Google or CalDAV
calendar that names a default assignee in the sync settings now works in both directions: events
imported from it get that person, and a new event assigned to exactly that person gets that
calendar as its target in the event dialog. Your own choice in the dialog always wins, and
without a match the personal default target from Settings applies as before. Nothing is picked
when two people are assigned, or when two calendars name the same person - the dialog says so
instead of guessing. Existing events are never moved on their own, and Apple and Outlook
calendars are not chosen this way; the sync settings say both where the default assignee is set. -
The calendar filter can hide a connected calendar or subscription, and show the events nobody
is assigned to (#1064). The filter sheet lists every calendar and ICS subscription with events
in the loaded range, each with its colour and a switch. Hiding one removes its events from all
four views and the agenda, remembered on the device for each account. A hidden calendar stays in
the list under its name even when none of its events is on screen, so it can always be switched
back on. A new event created for a hidden calendar stays out right away, not only once it has
synced. The person axis gains "Unassigned": on its own it shows only the events and tasks nobody
is assigned to, together with people it adds them. A person filter saved before this update keeps
its behaviour - it does not contain the new entry, so unassigned events stay out, as they did. -
An event's location opens in a map (#1110, from discussion #1047). The event detail carries
an "Open in Maps" action whenever the event has a location; it opens an OpenStreetMap search for
that text in a new tab, the same search Contacts already uses for an address. It is an explicit
action rather than a link on the location itself, because the field is free text: "Zoom" or
"Room 3B" is not an address, and the action never claims it is. The link is built on the device
and used only when tapped - no geocoding, nothing looked up in advance. An address imported over
CalDAV with escaped line breaks is searched as one line. -
A shopping item can carry a price and the shop it was bought at (#1003, first cut). Both sit
in the item dialog, where the item is already open - the checkbox stays the fastest gesture in the
app and gains no second step. The price is stored in whole minor units (cents, yen, fils) rather
than as a decimal: the purchase history this is groundwork for adds these numbers up, and money in
a floating point sums visibly wrong.The shop is a managed list, not free text on the row: two spellings of the same shop would
split that history in half. The field is a combobox - choose an existing shop or type a new one,
which is created on save - so the first shop in a fresh household has a place to come from without
a second dialog over the first. Renaming and deleting live under "Manage shops" in the list menu,
the same component that manages the categories. Deleting a shop keeps the prices and only clears
the assignment: what was once paid stays true.What is not here yet is the matching of an item to its earlier purchases - whether the same list
text is enough or something more stable is needed. Until that is decided, a price is a note on the
item, and no history is derived from it. -
A budget entry can name who is responsible for it (#1057, first cut). One or more household
members marked as looking after an entry - "who handles the water bill" - picked in the entry
dialog and shown as avatars on the row. It moves no money. Marking someone responsible
creates nothing they owe; settling up between people stays in Split Expenses, and the hint under
the picker says so.It is deliberately not
owner_id. That column is the privacy axis: it is fixed to the
creating person and not editable, because the visibility of private entries hangs off it.
Reusing it would have handed the responsible member the private-entry semantics of the row - a
permissions bug that looks like a feature. Responsibility is a second axis, in its own table, so
several people can share one entry.On a recurring series the label belongs to the series: newly materialised instances inherit it,
and editing the series moves it on every instance from today onwards while already-booked months
keep whoever was responsible then. Unlike the account, a virtual series inherits it too - the
label cannot distort a balance. In a one-person household the picker does not appear at all.The overview can be filtered and grouped by responsible member: clicking the avatars on a row
filters to that person, a chip clears it again, and a toggle groups the list. The groups are
deliberately not disjoint - an entry two people share appears under both - so each group head
carries a count rather than a sum; a per-group total would invite adding them up, and the total
would be wrong.A handover to Split Expenses sits under the picker: it switches to that tab and opens a new
expense with the title, amount and date filled in and the responsible members pre-selected as
participants. The dialog is not skipped - the split method, the currency and the group are
decisions Budget cannot make, and the claim only comes into existence once it is confirmed there.
Responsible members who are not in the group are dropped from the pre-selection; if none are left,
it falls back to the group's default split rather than an expense with no participants. -
Inventory items and subscriptions can record the account they are registered under (#1004).
One field per module: the e-mail address or username a device or a service runs on. It is
deliberately not a password field and never will be - a username without its password is a
phone-book entry, which is why it can live unencrypted in the normal database and be searched
like any other text. The permanent boundary is indocs/SCOPE.md, section 2, and the field's own
hint says so where it is filled in.On inventory the field is household-wide, and that is a decision rather than an oversight:
inventory_itemscarries neither an owner nor a visibility, access is decided once per member at
module level (#467), and an owner-scoped field would have meant inventing an ownership model for
the whole module just to hold one column. The reporter chose that himself - account names are
usually e-mail addresses, and anyone already trusted on the network has seen those. On
subscriptions the column sits in a row that already hasowner_idandvisibility, so it
follows both without extra work. Inventory's own search matches on it too, since "where is the
device that runs on this address" is the question the field exists for. -
Planned meals show their recipe's picture, for recipes mirrored from Mealie or Tandoor
(#1059, step one). The thumbnail proxy has existed since the provider sync landed, but only the
recipe list used it; the meal planner and the "today's meals" tile rendered text. Both now show
the picture where the recipe has one, so a household running a recipe manager gets a visual
planner with no new field and no upload. Recipes typed into Yuvomi still have no image - that is
step two, and it is the storage work.A card without a picture is untouched: no placeholder, no indent, the same title width it had
before. The first cut gave every card an image slot so all of them would line up, which turned
out to be exactly the layout change the issue rules out - measured in the week view, the slot
cost 32px plus spacing out of a roughly 100px column, in every cell, and a household without a
recipe manager would have paid a third of its title width for a meaningless cutlery icon. Row
height is what stays equal: the same card measures 121px with a picture and 121px without. -
A recipe typed into Yuvomi can carry its own picture (#1059, step two - the part most
households need, since most do not run a recipe manager). One image per recipe, chosen and cropped
in the recipe dialog the same way an inventory photo is, and shown wherever the provider thumbnail
already appeared: the planner and the "today's meals" tile. Where a recipe has both, its own
image wins - someone who uploads one has chosen that picture.The image is served from
GET /api/v1/recipes/{id}/imagerather than travelling with the recipe.
The column holds a data URL of up to 5 MB; shipping that with every row of a recipe list, or with
a week of meals, would have dwarfed the rest of the response for a 32-pixel preview. Lists carry a
has_own_imageflag instead, and the stored data URL never leaves the server as part of a record.
Saving a recipe without touching the image leaves it alone; only an explicit clear removes it. -
A household can name the four meal slots itself (#1058). Breakfast, lunch, dinner and snack
are now shown under whatever your household calls them - set in Settings → Modules → Kitchen,
next to the switch that decides which slots appear at all, because which slots and what they are
called is one setting. The name is not a translation: it shows in every language exactly as it
was typed, which is the point -fr,fr-CAandfr-BEdo not agree on what the evening meal
is called, and no locale file can settle that per household. An empty field means the built-in
word, so nothing changes for anyone who does not rename. The slot keys are untouched: recipe
suitability, the Mealie and Tandoor mapping and the planner rows all keep working, and nothing
migrates. The four slots now come from one place in the client rather than five copies, so the
planner, the overview tile and the recipe form always say the same word. -
Marking a housekeeping visit as paid asks first, and an admin can take a payment back
(#1136). "Mark paid" settled a visit with a single tap - in the report list, in the visit report
and in the staff log alike - and checked off the visit's payment task on the way. A paid visit is
settled: from then on only an admin can edit or delete it, so a stray tap was not a small thing.
All three buttons now open the same confirmation, and it names what happens: the linked payment
task is checked off, and only an admin can undo it afterwards. Cancelling from the visit report
leaves the report open. The way back sits in the visit report: on a paid visit an admin sees
"Undo payment", which marks the visit as pending again and reopens a linked payment task. Whether
that button appears is decided by the server for each visit, and the server checks the role again
when the payment is taken back.
Changed
-
Dashboard widgets share one header grammar, and more section headings adopt the shared title
style. Three widget header treatments coexisted on the dashboard: most widgets carried a
module seal, a title and an optional "view all" link, but Weather and Clock opened straight into
their content with no header at all. Both now open with the same seal+title header as their 15
neighbours - the large temperature and the clock face stay exactly as prominent as before, they
just get a name above them like everywhere else. The metrics tile row is deliberately left alone:
each tile already names a different module with its own seal and label, and forcing one title
over several modules would misrepresent it, not fix it.Separately, Rewards' four section headings and a genuinely unstyled Inventory category heading
now use the sharedu-section-titlerole instead of a private declaration that had drifted a
few pixels off it (Inventory's had no declared size at all - it inherited the browser default,
not any design token). Notes' and Budget's section headings, already the right size through their
own container rule, now say so directly in markup as well, growingu-section-title's adoption
beyond the two files it was previously confined to..inputand.form-inputstay aliased to the same rule - renaming roughly 300 existing uses
is not worth the review cost - but the alias site and DESIGN.md's Inputs/Fields section now say
which one is canonical for new code:.form-input, the name.form-group/.form-field/
.form-labelalready use. -
A scaled ingredient quantity is now written in the household's own digits. Scaling a recipe
wrote the number in Latin digits even where the rest of the line used Persian or Arabic ones, so a
doubled "۲ x ۵۰۰ g" came back as "4 x ۵۰۰ g" - one line in two scripts. That was deliberate at the
time: the server could only read Latin digits, and a quantity it could not read dropped out of the
shopping list totals. Now that both sides share the same transliteration, the reason is gone.Quantities already stored stay readable, and so do quantities written before a household changes
its region: reading gives the configured region's digits precedence and falls back to every other
system, the same way the server does. Without that, this change would have produced data the app
itself could no longer read. -
The recurring-payment dialog now says that editing a series also rewrites its first booking
(#1035). A series original is two things at once: the template every future occurrence is built
from, and the first hand-entered booking.PUT /budget/:id/serieswrites title, amount, category
and account to that one row with no date predicate, so raising the rent for all future months
also rewrites what the very first month says - a booking that may be years old. Separating the
two meanings of that row needs a migration and a decision about whatrecurrence_parent_id IS NULLshould mean afterwards; until then the dialog where the choice is made states what happens.
The delete dialog is unchanged: "Delete entire series" already says it.
Fixed
-
Split Expenses no longer shows three ways to add an expense at once, or a second page title
under Budget's own heading. Viewed as Budget's Split Expenses tab, the tab used to offer its own
header button and its own floating button for adding an expense, on top of Budget's own generic
toolbar button and FAB - both of the latter only ever repeated the tab's own button under the
hood. Budget's generic add action is now switched off for this tab, the same way it already is for
Reports; the tab's own floating button is the one primary action, and its header button steps back
to a secondary one. The tab's own<h1>- a second page title stacked under Budget's - is now a
section heading instead, matching how it already looked in a lighter type size. Deleting a group
now gets the same restrained red treatment used for a destructive action elsewhere in the app,
instead of looking identical to editing or archiving it. -
The demo data's birthday reminders now come days ahead, not minutes before noon on the day.
The demo seed wrote reminder lead times as1d,3dand1w, while Yuvomi stores them as
minutes and reads only the leading digits, so they became one minute, three minutes and one
minute again: every demo birthday reminded shortly before noon on the birthday itself, and the
birthday form showed a lead time it does not offer.
The seed now uses the form's own values (a day, two days, a week - the former three days became
two), and a guard keeps it to those. Only a database filled byscripts/seed-demo.jsis
affected. -
A dismissed birthday reminder stays dismissed. Dismissing a due birthday reminder only lasted
until the next check: the sync that keeps a birthday's reminder in step looked for an active row,
found none, removed the dismissed one and created the same reminder again - so it was back within
a minute, and could be pushed a second time. The reminder now stays dismissed until its time
actually changes: a different lead time, a changed date, or next year's birthday. -
A housekeeper can check out again, and work a second session on the same day (#1133, #1138).
The one button that carries both directions was disabled while someone was checked in, and it is
the only thing that triggers the check-out path - so that path was unreachable: a household could
check a housekeeper in and never close the session from the app. The button now offers "Check
out" in that state. Behind it, two answers had collapsed into one: a worker's "currently working"
and "was here today" both reported the last session of the day, so someone stayed "checked in"
after checking out. They are separate again. The check-in route matched the same mistake and
refused a second check-in for the rest of the day, which made split shifts, a break with resumed
work, and two separate visits impossible; it now only refuses while a session is actually open.
Overlapping sessions stay blocked, and each session keeps its own rate, calendar event and
payment task. The line under the name still shows today's visit once it is closed. -
The formatting toolbar over a task's note shows its icons again (#1141). Switching a task's
detail view into edit mode builds that form only then, but the icon-replacing pass over the whole
overlay had already run before the form existed, so the 13 buttons of the markdown toolbar (bold,
list, link, and so on) stayed blank. The edit form now gets its own icon pass right after it is
built. -
An event moved to another CalDAV calendar can be deleted or edited right away (#593). A move
creates the event in the new calendar and removes it from the old one, but until the next sync
Yuvomi kept pointing at the old copy. Deleting the event in that window went to an address that
no longer existed, counted as done, and the next sync brought the event back from the new
calendar. An edit was dropped the same way, and moving the event back to where it came from was
not recognised as a move. The event now points at the new calendar and its new copy as soon as
the move succeeds, as moves to Google calendars already did. An event deleted while its move is
still under way has its new copy deleted as well, and an edit or a move made while a change is
still being sent to the server stays queued instead of being dropped. -
A change to a synced calendar no longer collides with a sync that is already running
(#593). Every create, edit and delete tries to reach the server right away, and the scheduler
runs its own sync every few minutes. Both did the same bookkeeping at the same time, so one
could clear the other's notes between two network calls: an event deleted while its move to
another calendar was still under way left its new copy behind, and the next sync brought the
deleted event back. A provider now runs one pass at a time - the immediate attempt, the
scheduled sync and a second scheduled tick wait for each other instead of overlapping, and a
burst of edits during a slow pass is followed by one catch-up pass rather than one per edit.
This covers Google, CalDAV, iCloud and the CalDAV reminder lists behind Tasks and Shopping. -
Keyboard focus comes back after a confirmation, an input dialog or the calendar's detail
popover (#1083). Confirm a delete, rename a list or a subtask, pick a folder to move to, and
the page reloads its list - which rebuilds the very button you came from. Focus fell to the page
body, so keyboard and screen-reader users started over at the top. These paths now put focus back
on the rebuilt control, or on the page itself when that control is gone: in Tasks, Shopping,
Documents, Health, Subscriptions, Housekeeping, Inventory, Meals, Rewards, the Schedule, Split
Expenses, the quick links and several Settings pages (API tokens, invitations, document storage,
recipe providers, calendar subscriptions and accounts). On desktop, the calendar's detail popover
now returns focus to the event it was opened from when it closes through Escape or one of its
actions; a click elsewhere still leaves focus where it went. Deleting a contact or an event from
its detail view puts focus back on the page as well.Where a dialog appears on only some paths - rejecting a reward redemption asks, fulfilling it does
not - focus is pulled back only on the path that asked. Otherwise it would land on the trigger of
some earlier, unrelated dialog. The guard that holds all of this learned three shapes it could not
see: dialogs that deliver their answer after closing (confirmModal,promptModal,selectModal,
confirmOverModal), a reload inside atryblock or after anif, and a callback handed in as a
parameter. -
An edit or a move made while a change is still on its way to Google is no longer dropped
(#593). A change to a synced Google event is sent in the background. If the event was edited
again, or moved to another calendar, while that request was still under way, finishing it cleared
the note that more was waiting: Google kept the older version, and the next sync could write it
back over the newer edit. A second move made during a move was lost the same way, and moving the
event back to its old calendar while the first move was running was not recognised at all. Only
what actually reached Google now counts as done, and anything newer stays queued for the next
attempt. An edit made while the event is being moved goes to its new calendar in the same run
instead of waiting for the next sync. -
Choosing the calendar an event already sits in withdraws a move that has not gone out yet
(#593). A move to another calendar is queued and carried out by the sync, and that attempt can
fail - the server may be unreachable right then. Choosing the original calendar again in that
window looked like no change at all, so the queued move stayed: the next sync moved the event into
a calendar nobody had chosen any more, while the dialog showed the one that was. A target pointing
back at the calendar the event sits in now withdraws the queued move, and a target pointing at a
third calendar replaces it. Only the choice made in that edit counts: an edit that leaves the
target alone keeps a queued move, and a target stored on an older event that differs from its
actual calendar is still never read as a wish to move. Withdrawing also works while the sync is
set to read-only or its account is gone - it changes nothing at the provider, and a move the user
took back must not come back to life once writing is allowed again. Google and CalDAV alike. -
The event detail names the day a multi-day event ends (#1102). The "When" row showed the
start date and, of the end, only the time: an event from 10 September 14:00 to 12 September 11:00
read as "14:00 - 11:00" on a single day that ends before it begins, and an all-day event across
three days named only the first. When an event ends on another day, the row now carries that day
as well - start date and time to end date and time, or first to last day for an all-day event.
"Another day" is the rule the calendar grid already uses rather than a second one: a timed event
that ends at 00:00 still belongs to the evening it started in (#804), and the end of an all-day
event stays inclusive. The range separator now comes from the same locale string as the day
view's date range, a hyphen where the time range used to carry an en dash. -
Dialog content on a phone scrolls again with Reduce Motion turned on (#981). A freshly opened
dialog stands at the top, so every swipe inside it began as a tracked swipe-to-close gesture, and
on every upward frame that gesture wrotetranslateY(0)to the panel. Normally the sheet's
entrance animation holds its end state and outranks that inline style, so the write changed
nothing. With Reduce Motion the animation is switched off, the write turned the panel's transform
fromnoneinto a matrix on every swipe, and iOS dropped the scroll. Measured in the iOS simulator
with the setting on: the same upward swipe left the content 0 to 30 px down in three runs. An
upward movement of more than 10 px before the sheet has been pulled is now content scrolling -
the gesture lets go and never touches the panel's style. Below that, the same threshold that
already applied downwards, nothing is decided, so a finger that wobbles upward as it lands can
still pull the sheet closed. A pull that has already started stays tracked when the finger
reverses, so the panel still returns to rest. Under the same setting the swipe now ends
500 to 675 px down. -
The Module options settings page describes what it actually contains. Its description named
only Budget, Health and Housekeeping - accurate when it was written, but Tasks and Schedule have
since grown their own sections on the same page without the sentence ever being updated. Reworded
to describe the page's purpose instead of enumerating its sections, so it can't go stale the same
way again the next time a module gains a section here. -
The person filter in the task history is no longer a row of blank buttons on a phone (#1068).
Below 640px the label-loss rule removes every.group-toggle__label; it is built on the
assumption that an icon stays behind, which is true for the view switcher next to it. These chips
had none, so nothing remained but the tinted surface of the active one. The loss was not only
visual:display: nonetakes the text out of the accessibility tree as well, so the buttons were
just as nameless to a screen reader. Each chip now carries what the rest of the module already
uses to identify a person - their avatar, the same disc the history rows below it show - and
"All" gets an icon from the same family as the switchers. The name itself moved onto the button
asaria-label, where no media query can take it away. -
A recurring event synced from Google no longer shows an end time hours after its start
(#1089). Every occurrence of a series is generated from the master, and its end was derived by
adding the duration to the new start. Which format that end was written in depended on a test
that asked the wrong value: it looked for aZin the master's start, while the occurrence's own
start may well carry a numeric offset (15:25:00-04:00, as Google sends it) or be rebuilt as UTC
from the series timezone. Neither matched, so the end went down the wall-clock branch and was
formatted with the SERVER's local getters. The row then held two storage formats at once: an
instant for the start, a zoneless wall-clock time for the end. The browser converts only the
first, so the two stood side by side on different clocks - off by exactly the offset between the
server and the viewer, which is why it stayed invisible wherever the two agree. Reported from a
UTC server viewed in New York: 3:25pm to 3:30pm was displayed as 3:25pm to 7:30pm. The end now
follows the storage format of the start it belongs to. Locally created series are unaffected:
there both sides read the same server zone and the conversion cancels out. -
Late Notes saves no longer close a replacement dialog or hide a failed save. A save response
now belongs to the editor that started it. If that editor has already closed or is waiting behind
a discard confirmation, the current dialog and its unsaved fields stay intact; network failures
remain visible in the global error toast. A late category-name conflict follows the same ownership
rule: it offers the rejected name again only while the page and dialog flow that requested the
rename are still current, and otherwise reports the conflict without replacing newer work. -
An ingredient written in the household's own digits now counts towards the shopping list.
Moving a meal plan to the shopping list adds up the same ingredient across meals. The server read
the quantity with an ASCII-only pattern, so a Persian, Arabic, Hindi or Thai amount - "۲۵۰ g" -
matched nothing at all, and the ingredient dropped out of the totals: the list showed it twice
underneath itself instead of once with the sum. A household using its own digits quietly got a
worse shopping list than one using Latin ones.The server has no locale, so it now accepts every digit system rather than one region's. The
mapping is derived fromIntlrather than kept as a table - 770 digit characters across 77
systems is not something anyone would keep current by hand. Only real digits count: the one system
whose "five" is an ordinary Chinese character is left out, so a五in an ingredient stays a word.Two details follow from what a character actually means. The Arabic thousands separator says so
unambiguously, unlike a comma, so "١٬٠٠٠ g" now reads as a thousand grams; a plain "1,000 g" is
left as it was, because there the server cannot tell grouping from a decimal point. And a fraction
stays on the text path rather than being read as its numerator - "١/٢ kg" was never a quantity of
one, and neither was "1/2 kg", which had been getting that wrong unnoticed.Quantities written with foreign characters go through a stricter reading than plain ASCII ones,
because they had no behaviour at all before: a grouping has to look like one along its whole
length, several groups are read as one number, and a comma inside Bengali or Devanagari digits is
refused rather than guessed - it groups there, so reading it as a decimal point would be off by a
thousand, and the server cannot know which was meant. Plain ASCII quantities keep reading exactly
as they always have; changing that is a decision of its own. -
An edit made while the list is refreshing is no longer thrown away. Checking an item off the
shopping list, or stepping a pantry quantity up or down, marks the row immediately and sends the
change to the server behind it. Both pages stay usable while a refresh is in flight - after
managing categories or storage locations, after switching lists, after importing a meal plan. A
refresh that had read the server before the edit arrived back after it, carrying the older
value, and overwrote what had just been changed. The row jumped back, the counter beside the list
tab disagreed with it, and the next tap sent the wrong value on - unchecking something the server
considered unchecked already.The item was never actually lost - the server had it - which is what made this hard to see: a
reload showed the right thing, so the wrong row only lasted until the next visit to the page.Pending edits now survive a refresh. Each one is remembered until a refresh comes back that
demonstrably started after the server confirmed it, and only the affected rows are re-applied on
top of the fresh data. Everything else in the response lands untouched, so the refresh still
delivers what it ran for. Discarding the whole response instead would have taken the renamed
categories with it - the very thing the refresh exists to bring. A refresh that is overtaken by a
later one now steps aside rather than writing an older picture over a newer one.A refresh answered from the offline cache no longer counts as proof.
/shoppingis in the service
worker's read-only offline whitelist, so on a dropped connection the last cached response comes
back with its original success status and is otherwise indistinguishable from a fresh one - while
being arbitrarily old, since writing does not clear that cache. Taking it at face value put the
pre-edit value straight back on the row, which is exactly the situation this is for: standing in
the shop on a bad connection. The read now carries whether it came from the cache.If the change to the server does fail, the row goes back to what the server last said rather than
to what it showed before the tap. Those are the same value in the ordinary case and differ exactly
when someone else in the household changed the same row in the meantime - and then the value from
before the tap is a number the server has never held.In the pantry the pending step also lost track of its own row: a refresh replaces the stored items
with new objects, and the delayed request still held the old one, so the server's answer was
written into an item that no longer belonged to anything. The row and the item are now looked up
again when the answer arrives. -
A saved filter no longer offers a category, tag or person that has been deleted. The Tasks
filter bar keeps the last three filter sets as one-click chips. Nothing checked whether what they
name still exists, so deleting a category, renaming or merging a tag, or removing a household
member left a chip that put the dead value straight back into the query on click. The list then
filtered on something the server has never heard of and stayed empty - and reloading did not help,
because the value lives in the browser's local storage.The chips are now filtered when they are read rather than cleaned up when they are written: a
single place decides it, and it stays right even when the change happened in another tab or on
another device. A set that has nothing left to offer disappears from the bar. Nothing is rewritten
in storage, so a category that comes back brings its chip back with it, and a load error - where
the app has no reliable list to compare against - leaves every chip alone rather than sweeping
them away. Offline counts as such a case: the list of categories, tags and members can itself come
from the offline cache and be arbitrarily old, which would hide a chip that is still valid just as
readily as it would keep a dead one. -
Deleting a category now updates the page behind the dialog. Every module that offers
"manage categories" kept showing the category you had just deleted: the filter chips in Contacts,
the grouping in Shopping, the storage locations in Pantry, the places and categories in Inventory,
plus Tasks and Budget. The server had deleted it, the screen had not noticed, and picking the
stale entry afterwards ran into an error from a category that no longer existed. A reload fixed
it, which is how it stayed hidden.The cause was a matter of order. Confirming the deletion closes the dialog first and sends the
request second, so the "something changed" signal arrived after each page had already stopped
listening. Refreshing now happens when the change actually lands rather than when the dialog
closes.Shopping had a second version of the same staleness, and renaming triggered that one: it stores
a category by its name rather than by an internal key, so both renaming and deleting rewrite the
items themselves. The list only reloaded its categories, leaving the affected entries under their
old heading at the bottom of the list. It now reloads the items with them. -
A fractional ingredient quantity with a stray separator is no longer scaled into a wrong
number. Scaling a recipe reads a leading fraction like "1 1/2 cups" as well as a plain amount.
Where a denominator ran straight into a separator - "1/2,5 cup" - only the "1/2" was read, the
result was multiplied, and the leftover ",5 cup" was appended, so doubling it produced "1,5 cup":
a quantity that looks deliberate and is wrong. The check that already refused this for plain
amounts now covers the fraction forms too, and such a line is left exactly as written. -
Scaling a recipe now reads and writes ingredient quantities in the region that is actually
set. Applying a recipe to a meal and changing the servings factor rescales every ingredient, and
that step parsed the number itself with the comma hard-wired as a decimal point. Under a region
that groups thousands with a comma, "1,000 g" was therefore scaled up from 1, putting an
ingredient in the recipe a thousandfold too small with nothing to show for it. Under Persian or
Egyptian Arabic the number was not recognised at all, so that line stayed at its original amount
between correctly scaled siblings - the recipe was simply wrong.The result was written the same way it was read: the separator was copied off the input, so a "1.5"
mirrored in from Mealie or Tandoor stayed "1.5" in a German kitchen. Both directions now follow the
set region - the reading side through the same transliteration as prices and shopping quantities,
the writing side through the same number format - so a scaled quantity comes back out in the
notation the household reads, and the app can read its own output again the next time. The
separator is presentation and follows the region wherever
the region uses one the server reads - a comma in German, French or Czech, a dot in US English or
Swiss German. Persian and Arabic use a third one, and there readability wins and the dot is
written.A grouped quantity is refused rather than guessed, and refusing here means the line is left exactly
as it was: the quantity is the ingredient's own text, and the original is the only answer that
invents nothing. The same applies to a number that breaks off mid-separator - "1,5 kg" under a
region where the comma separates nothing would otherwise have been read as 1. Fractions ("1 1/2
cups"), plain amounts and free text like "a pinch" keep behaving as they did. -
A quantity like "1,000 g" on the shopping list is no longer read as 1 before it goes into the
pantry. Taking a checked item over to the pantry pre-fills the quantity field from the free text
on the row, and that step parsed the number itself, with the comma hard-wired as a decimal point.
Under a region that groups thousands with a comma - en-US among them - "1,000 g" therefore arrived
as 1 g, off by a factor of a thousand, and nothing said so. Under Persian or Egyptian Arabic
the number was not recognised at all: the field shows the region's own digits, and a quantity typed
in them fell back to "1 piece" no matter what it said.The number now goes through the same transliteration as the price fields (#1003): the digits and
the decimal separator come from the region that is actually set, and a grouped number is refused
rather than guessed - "1,000 g" could mean one gram or a thousand, both readings are defensible,
and the wrong one is off by a factor of a thousand. Refused means the row falls back to "1 piece",
which says visibly that nothing was understood, in a dialog where the quantity sits in a field you
can correct before it is saved. The same applies to a number that breaks off mid-separator, which a
grouping check cannot catch: "٢٬٥٠" has only two digits after the separator, and under Persian the
ASCII comma separates nothing at all. Only the leading number decides: "6 x 1.000 ml" is six bottles
of a litre each, and the 1.000 further along - which is never read - does not make the line
unreadable. "1,5 kg", "250 g" and "6 x 1 l" keep reading exactly as they
did. -
Closing a dialog no longer drops keyboard focus when the button that opened it was re-rendered
meanwhile. The shared modal layer remembers the element that opened it and hands focus back on
close. If the page had swapped that button out in the meantime - the category manager re-renders
its section after every rename, reorder or new entry, while the dialog is still open - the
remembered pointer referred to a node no longer in the document. Callingfocus()on it does
nothing at all, silently: focus fell todocument.body, and anyone working by keyboard or screen
reader lost their place in the page and had to tab in from the top.The layer now checks whether the remembered element is still connected, and falls back in two
steps: it looks for a live element under the same id, which finds the button that was rebuilt in
the same spot, and otherwise puts focus on the page root - the same target the skip link uses. Not
a good place, but a place inside the page, whichdocument.bodyis not. The root is made
focusable first: the app shell gives ittabindex="-1", but the five auth pages render their own
<main id="main-content">without one, and focusing an element that cannot take focus is the very
no-op this entry is about.The same break has a second, more common shape: a handler that re-renders after the dialog
closed -closeModal()andrenderGrid()on the next line. There the restore was correct and got
re-rendered away a moment later, which no check at close time can see. Measured: 30 such places,
and the typical trigger there is not a toolbar button but a list row - a note card, a meal
cell - which carriesdata-idordata-actionrather than an id. The layer now looks the element
up again by those attributes, and where the target is destroyed right after the restore it takes a
second pass on the next frame: only if the target really vanished, only if focus actually fell to
document.body, and only if no dialog has opened in the meantime. Where nothing broke, nothing
moves - the common path is unchanged.Eleven of those places re-render after an
await, which is past that frame. There only the page
knows when it is done, so it says so:refocusAfterRender()runs the same three checks and does
nothing where nothing broke. A scanner in the test suite finds the pattern rather than a list of
files, so a new place that re-renders after anawaitis caught without anyone editing the test.A third shape hides between the two and was found in review: a handler that re-renders
asynchronously while the dialog is still open -await loadBudgetMeta(); renderBody();in the
category manager. Close the dialog while that request is in flight and the opening button is still
connected, so the restore correctly lands on it and the re-render detaches it a moment later.
Measured in the browser, focus ends up ondocument.bodyagain. Eleven handlers of that shape now
pull focus across their own re-render, with a second scanner holding the line.Where the trigger is a list row, the row itself is what identifies it: inventory and pantry put
data-idon the row and onlydata-actionon the button inside it, so every row looks alike from
the button's side. The lookup now carries the row it sat in, and where more than one candidate
still matches it returns none and falls back to the page root, because a wrong focus target puts
the reader somewhere they did not choose. Not everydata-value carries identity, though: a
subtask's rename button also holds its title, and that is what just changed - so the lookup makes a
second pass on the identifying fields alone, still insisting on a single match.The scanners look through wrappers as well: a handler that awaits
reload()rebuilds the page just
as much as one that callsrenderContent()directly, and the name says nothing about it. Counting
only names beginning withrenderleft 25 places uncovered across six more modules. They follow
those wrappers through nesting, too -reloadMedViews()callsreloadMeds(), and only that one
reaches a render - and they count an awaited callback as a rebuild, sinceawait onChanged()
replaces the whole list without naming anything.Focus is now also checked for arrival rather than assumed: a rebuilt button can come back
disabled- the redeem button in Rewards does, once the points no longer suffice - and focusing it
is the same silent no-op the whole entry is about. Where it does not take, the page root does. And
where that root was chosen as a stand-in, a later rebuild is allowed to take the focus off it
again, so a loader that swaps its opener for a skeleton and rebuilds it after the request does not
leave the reader stranded at the top of the page. What decides is whether the target still holds
focus, not whether it is still in the document: deleting a task hides its row rather than removing
it, and a hidden row keeps its place in the tree while dropping focus tobody.Measured across the seven callers of the category manager, exactly one - the budget page - puts
its button inside the very section it re-renders while the dialog is open. The others keep theirs
in a toolbar their handler does not touch, and the shopping menu turned out to be a non-case: the
popover hands focus back to its trigger before the page handler even runs. -
Paying extra on a loan now shortens the remaining term, not only the balance (#964). Since
#954 the remaining principal follows the money you actually paid, but the remaining term beside it
stayed plan-based and still said 100 installments after you had doubled a payment - the exact
number the reporter was looking at. It now shows what the account balance implies, with the
contractual figure in brackets: 98 (plan: 100).Only that one figure moves. Monthly payment and total interest genuinely describe the contract -
the bank will not send a smaller invoice because you overpaid - and they stay as they are. The
remaining term is the one number in the group where the contract and the balance disagree and the
balance is what was being asked about. Both are shown so the contractual view does not quietly
disappear.The projection is arithmetic, not advice: it carries the contractual annuity forward at the
contractual rates, including the switch to the follow-up rate at the end of a fixed period. It
says nothing about whether overpaying is worthwhile - early-repayment penalties keep that question
out of scope (#935). Where the sum cannot be computed - an instalment that does not cover the
interest - the plan figure stands alone rather than an invented one. -
A month-end series in its own timezone no longer drifts across a DST change (#985). A series
imported over CalDAV or ICS carries the timezone it was created in. Its recurrence was computed on
UTC days, and where the UTC day and the local day disagree - a late-evening event, say 23:30 in
New York, stored as 04:30Z the next day - the month-end rule was suspended and the series ran on
its fixed UTC day instead. That fixed offset tracks the local month end only while the UTC offset
stays put. From the March transition onwards it does not: measured on a New York series at 23:30,
every following occurrence landed on the 1st instead of the last day of the month - not one missed
date, all of them.The recurrence now advances on the event's local date and converts back to UTC per occurrence,
soBYMONTHDAYapplies again to the date it actually means, and the local time of day stays put
across the transition. Events without their own timezone - everything created in Yuvomi - are
unaffected and take the same path as before.Two dates now run side by side, deliberately: the rule advances locally, while the display window,
theEXDATEexceptions and the emitted instance stay on the UTC day. Exceptions are
normalized to the UTC day on import, so comparing them against the local one would have made them
miss exactly the events this fix is about. -
A "last day of the month" series now leaves Yuvomi with a start date its own rule accepts
(#986). A series created from a mid-month date storesDTSTARTas entered - say 15 January -
together withRRULE:FREQ=MONTHLY;BYMONTHDAY=-1. Internally that is unambiguous, and the calendar
never shows the 15th. Outbound it was not: RFC 5545 3.8.5.3 calls the recurrence set of an
unsynchronizedDTSTARTundefined, so a subscribing client was free to render the 15th and
every month end - one occurrence more than Yuvomi shows. Every outbound path (ICS feed, CalDAV,
Apple, Google, Outlook) now emits the first date the rule actually matches.The end moves with it.
end_datetimeis an absolute timestamp, not an offset: leaving it behind
would have produced an event that ends before it starts. It shifts by the same number of days, so
the duration and the stored time format survive untouched.Two things stay exactly as they were. An imported series is handed back word for word (#756) -
a foreign calendar may carry an unsynchronizedDTSTARTon purpose, and Yuvomi is not the referee
on a round trip. And aBYDAYrule is left alone (#549), where a start on a weekend is a
deliberate, older decision. The transformation is read-only at the point of serialization: the
stored value never changes, which is what made the write-time attempt in #984 unworkable. -
An event from a subscribed calendar now names its source everywhere an event is read
(groundwork for #1064). A subscribed event already inherited its subscription's colour, but the
name came fromexternal_calendarsalone: the calendar list, the search and the dashboard all
readcal_nameas null for it, so it showed up in the subscription's colour without ever saying
which subscription that was. The detail endpoint went further and did not select the column at
all, for any event - its own comment promised "the same event object as the read path", and that
promise held for the colour only, so a freshly created or edited event came back without its
calendar name. All six queries now read the name from both sources, the way the colour already
did. An event with no source keepscal_name: null. -
The schedule overview now sets its blocks in the same size as the calendar (#1065). A block
in the overview carries what a calendar tile carries - a title plus one line of time and custom
field - but stood two type steps smaller than one, in narrower columns, which made a school
timetable hard to read at a glance. It now uses the same--text-xsthe calendar uses. The
height was already there: a 45-minute lesson is 42px tall on the condensed hour scale, and two
lines cost about 35px including padding; measured across 60 blocks, none clips in either
direction. Putting the custom field on a third line of its own, as the report asked, would need
about 51px per block and therefore a taller hour scale - that is a change to the scale, not to
the block, and is not part of this fix. -
Editing one occurrence of a local recurring event now keeps it linked to its series (#975).
The edited occurrence keeps all three series scopes when reopened, follows later series changes
for fields that were not deliberately changed, and keeps its original recurrence slot even when
moved to another date. The replacement and its skipped original slot are saved atomically, and
the read-only ICS feed now exports the replacement with standardRECURRENCE-IDsemantics.
Imported series keep their existing whole-series behavior. Generated local series and local
series targeted for outbound sync retain their previous standalone-edit and deletion scopes.
Historic detached edits are left unchanged rather than guessed back into a series. iCloud
auto-sync excludes linked replacements and their masters, without excluding ordinary
deletion-only exceptions.
Detaching a linked replacement retains its original-slot exception, so outbound targeting or a
recurrence-rule round trip cannot resurrect a duplicate master occurrence. Changing a whole-series
recurrence rule no longer forgets previously deleted occurrences. Truncating a series likewise
retains later exclusions, and splitting a linked series transfers every later exclusion except the
new anchor even when the successor rule cannot currently reach it, so a later extension cannot
resurrect a deleted slot or duplicate a detached replacement. A no-difference only-this save
removes an exclusion only when it also removes the linked replacement that owned that exclusion.
Save confirmations preserve entered values on validation or server errors. Outlook checks actual
writable push targets before accepting linked-series auto-sync, and MCP upcoming results retain
their unrestricted future horizon while recurrence generation stops at the requested result count.
ICS deletion exceptions keep the series' local time across daylight-saving changes even when
the stored UTC day differs; each exception needs at most three local-date candidates, not a
series scan.