github ulsklyc/yuvomi v2.66.0

2 hours ago

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 in docs/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_items carries 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 has owner_id and visibility, 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}/image rather 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_image flag 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-CA and fr-BE do 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 shared u-section-title role 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, growing u-section-title's adoption
    beyond the two files it was previously confined to.

    .input and .form-input stay 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-label already 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/series writes 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 what recurrence_parent_id IS NULL should 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 as 1d, 3d and 1w, 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 by scripts/seed-demo.js is
    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 a try block or after an if, 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 wrote translateY(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
    from none into 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: none takes 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
    as aria-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 a Z in 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 from Intl rather 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. /shopping is 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. Calling focus() on it does
    nothing at all, silently: focus fell to document.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, which document.body is not. The root is made
    focusable first: the app shell gives it tabindex="-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() and renderGrid() 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 carries data-id or data-action rather 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 an await is 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 on document.body again. 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-id on the row and only data-action on 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 every data- 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 calls renderContent() directly, and the name says nothing about it. Counting
    only names beginning with render left 25 places uncovered across six more modules. They follow
    those wrappers through nesting, too - reloadMedViews() calls reloadMeds(), and only that one
    reaches a render - and they count an awaited callback as a rebuild, since await 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 to body.

    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,
    so BYMONTHDAY applies 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,
    the EXDATE exceptions 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 stores DTSTART as entered - say 15 January -
    together with RRULE: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
    unsynchronized DTSTART undefined, 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_datetime is 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 unsynchronized DTSTART on purpose, and Yuvomi is not the referee
    on a round trip. And a BYDAY rule 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 from external_calendars alone: the calendar list, the search and the dashboard all
    read cal_name as 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 keeps cal_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-xs the 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 standard RECURRENCE-ID semantics.
    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.

Don't miss a new yuvomi release

NewReleases is sending notifications on new releases.