Skip to content
KAMOUniverse

Changelog

What's new.

Every improvement to the platform, as it ships.

  1. · kamo-internal

    A wall at "now", and a card that explains a cancelled punch

    Nobody can record time they have not worked yet. Dragging an edge, sliding a whole span, nudging with the arrow keys, the magnet, the right-click block and the manual add dialog all stop at the current minute on today's row, and a future day accepts nothing at all. The part of today that has not happened is hatched, because "you cannot drag a pill here" has to be visible BEFORE someone control, not as a rule. The server already refused a punch inserted or corrected past now, which is exactly why the client needed this: without the wall the drag succeeded, the pill sat there looking staged, and the whole save failed at the end with a message about a punch the member could not find. The magnet gets the same wall. A punch left in the future by a wrong device clock was a legitimate snap target, so an edge could be pulled forward onto it and land somewhere the save would refuse. And the confirm card now stays open when a punch CANCELS OUT the one before it, which TimecardService started doing this morning for any reversal inside a minute. Without that the member presses a button, watches the state snap back to exactly where it started, and reasonably concludes the button is broken. In that state the card drops its picker and its action copy, shows one sentence and one button, and stops dismissing on the backdrop — the stray click that so often follows a confirm must not close the only explanation of what happened.

  2. · kamo-internal

    Give the support and social windows the member chat's composer

    A support ticket and a social DM are the same thing as a member chat — a media session with messages on it — but they were answered through a bare text field. An agent could be sent a screenshot by a customer and had no way to send one back: no attach button, no drag-drop, no paste, no emoji, and an input that would not grow past one line. Both now render the member chat's Composer, so the upload machinery, the attachment tray, the transfer panel, the emoji picker and Enter-to-send are one implementation across all three. Received and sent attachments render in the bubbles via the same AttachmentChip. Two things the tabbed support window forced out into the open: - Drafts are parked per conversation (useDraftParking). One composer serves several tickets and the send posts to whoever is on screen, so a draft left in the box is one customer's words aimed at another. Discarding after a send takes the key the send started with, or switching tabs mid-flight would wipe the reply being typed to somebody else. - Staged attachments are held per session too. A flat list put one customer's file in another's tray and uploaded it there; uploadAll now addresses the session the files were staged for, whatever tab is in view when it lands. Social replies are capped at one file of 10 MB: a message carries a single MediaObjSocialRef, so a second attachment could be stored and never relayed, and 10 MB is the tightest provider ceiling we send through. Refusing it in the composer beats losing it silently. A failed send is now reported separately from a failed upload — the files are up, the message is not, and the draft is still there to try again.

  3. · kamo-internal

    Speed dial lists the org's department extensions

    The tab shipped six invented entries — Sales Team on 1001, a Conference Room permanently ringing — so dialling one reached whatever happened to answer that extension, and the BLF badges reported presence nothing was subscribed to. It now reads /api/security/departments and lists every department the org has given an extension, sorted by title, with the outside number underneath when one is set. Loading, empty and retryable-error states replace the tiles that could never be empty. The GET is org-scoped and needs no right, and it fires only once the tab is first opened.

  4. · kamo-internal

    Widen chat, social, ai-chat and compose windows to the support size

    The support ticket window opens at 460px while member chat, social chat, AI chat and the email composer all sat at 360-420. Same dock, same kind of content — they now share the support window's 460px default (and a 420px floor for the resizable ones), so a row of open tool windows lines up instead of stair-stepping.

  5. · kamo-internal

    Show unseen messages on every queue row, not just joined ones

    The badge and the row tint were already here — they were simply never populated for most of the list. getUnreadFor read only the live per-session unread map, which is derived from the viewer's own membership row, so a ticket they had not joined could never light up. On this queue that is most of it, and the unassigned tickets nobody has picked up are both the majority and the rows that most need the flag. It now prefers the live map, which is authoritative for conversations the viewer is in and updates over STOMP the moment they read or a reply lands, and falls back to the server's per-ticket count for everything else. The two cannot be merged blindly: the live map only ever carries sessions that still have unread messages, so "read a second ago" and "never counted" are indistinguishable in it — hence the small set of conversations opened during this visit, which keeps a row the member is currently reading from staying lit until the next reload. The list also re-reads itself when the support pending count moves. For a conversation the viewer has not joined there is no live channel that would ever correct the snapshot — the unread topic only reaches people in the session, which on a queue is nobody — and pendingCount is the one signal that does reach every support-capable member of the org.

  6. · MediaService

    Stamp unseen counts on the ticket list, and keep the queue live

    getTickets now bulk-loads an unseen-message count per row for the caller, beside the visitor rows it already loads that way, and getTicketDetail computes its own before the reader's markAsRead lands — so the window can show what arrived while they were away rather than a number the act of opening it already zeroed. Also closes the signal gap that left the queue stale. A message in a support conversation moves the queue for every support-capable member of the org, not only the people in that session — which is exactly who the per-session unread topic does NOT reach. The pending-count broadcast already fired when a public-chat visitor sent and when an agent marked read; a member replying from inside the app was the hole, so an in-app support conversation could gain messages with nothing telling any agent's screen to look again. SupportUnseenCountSqlTest pins the query's shape. Every clause in it is load-bearing and none of them is visible in a passing build: get one wrong and the queue still renders, just with counts that are quietly false.

  7. · kamo-shared-library

    Count unseen messages for a viewer who is not in the conversation

    Unread has always been derived from the viewer's own MediaSessionMember row, so a conversation they had not joined produced no count at all. On a support queue that is most of it — 27 of the 37 live tickets are unassigned, and 26 of those have exactly one session member, the person who asked for help. Those are precisely the rows that most need flagging, and they were the ones that structurally could not carry a flag: nobody had joined them, so there was no reader for them to be unread by. countUnseenBySessionForViewer answers the question a queue asks instead. The LEFT JOIN onto membership is the whole trick: with no membership row there is no read pointer, so every inbound message counts, which is the truth — the viewer has seen none of them. One query per page, matching how the visitor rows beside it are already bulk-loaded. System events are excluded. A status change or a reassignment is bookkeeping about the conversation, and counting it lights up rows where nobody has actually said anything — worse than no badge at all on a screen whose job is deciding what needs a reply. SupportTicketDTO.unseenCount is populated on the list and detail reads and left null everywhere else, because "unseen" is a fact about a viewer and a number invented for a caller who did not ask about one would simply be wrong.

  8. · kamo-internal

    Delete, magnets, a right-click menu, and a clock that asks which

    Six additions to the period graph and the nav clock. DELETE removes the focused pill. Backspace too, because on a Mac the key labelled "delete" IS Backspace and offering only Delete means the shortcut does not exist on a laptop. It voids both of the span's punches, so deleting a meal leaves the work either side of it continuous — which is what it means for the meal not to have happened. Staged like every other edit; nothing is written until save. A GUIDELINE follows the pointer across every row, with the minute it is on in a chip at the top. Its job is to say where a click would land, and that is the same minute on every day in the period. Measured off a track element rather than the container, whose box includes the day-name and total columns — using it would report a minute out by their width, which the member cannot see RIGHT-CLICK anywhere on a day opens a menu that drops a 15-minute block of worked time, meal or break where the pointer was. Two punches, not one: a span is a pair, and a lone opener draws a pill running to midnight. Note that a meal block OPENS with MEAL_OUT — a meal is recorded as clocking out of work and back in, and getting that pair backwards inverts the day. MAGNETS: an edge dragged within five minutes of another punch snaps onto its exact minute, deliberately bypassing the one-minute gap the clamp keeps. That gap stops an edge crossing its neighbour; here the whole intent is for the two to meet, and an edge that can only land a minute short can never close a gap. Five minutes at a fortnight's zoom is a couple of pixels, so without a magnet "make this break end when the next one starts" is a task nobody can complete with a pointer. And when the magnet lands two spans of the SAME kind edge to edge, they MERGE in the same gesture: the clock-out and the clock-in that now share a minute stop being true, and both are voided. Different kinds never merge — a shift ending exactly when a meal begins is not one span, it is the two things that actually happened. This needed the sort in toGraphPunches to tie-break on the original minute: two coincident edges must keep their chronological order or pairing swaps them, produces a zero-width span and a stray opener, and the pill vanishes at the moment the two meet. THREE PILL COLOURS, and distinct by more than hue — work solid in the brand primary, meal banded in the HRS amber, break finely striped in violet. Colour alone fails the ~8% of men with a red-green deficiency and fails everyone on a projector; someone should be able to tell an unpaid meal from a paid break in a black-and-white printout. THE NAV CLOCK ASKS WHICH PUNCH. "Clock out" is three different acts, and what is on offer depends on where the member stands: working, they may end the shift, go to a meal or take a break; on a meal or a break, coming back is the default and ending the shift is still there because people do leave straight from lunch. Collapsing that into one button is how somebody ends their day when they meant to go to lunch — an unpaid hour they do not get back and a missing-punch exception on the way. The card re-skins to the selected action in the same three colours the graph uses, says how long they have been in their current state, and the nav dot now reports which of the three states it is. An absent `state` from the server is read as "the boolean is all we have", not as OFF: reading it as OFF would offer "clock in" to somebody mid-shift for the length of the deploy window.

  9. · SecurityService

    Relay the punch void

    Passthrough for POST /punches/{id}/void, the editor's delete key and the machinery behind merging two adjacent spans. Same authority as /correct, and for the same reason: an EMPLOYEE may void their own punch, because a member who cannot say "that double tap never happened" has no way to correct a record they are the only witness to. What they cannot do is make it count — the void lands on a timecard that still moves through the approval chain, and the reason is mandatory on every path. The actor is stamped from the SESSION, never from the body; a body-supplied actor is a body-supplied signature on someone else's wage record. Two tests, pulling in opposite directions, because this service runs anyRequest().permitAll() with hand-rolled per-handler auth: it must refuse a colleague's punch without ever reaching the relay, and it must admit the subject themselves holding no rights at all.

  10. · kamo-shared-library

    A punch can be voided without being deleted

    Four nullable columns and a second write-once transition on TimecardPunch. A void says the punch should never have existed — a double tap on the clock, an abandoned break, or the seam between two spans the timecard editor has just merged into one. That is neither a correction ("this happened, at a different time", which leaves a replacement row) nor a delete: supersededByPunchId means "read this other row instead", and here there is no other row. The row survives, voided, with its author and its reason beside it. A wage record that can be made to forget a punch is exactly the inadequacy that shifts the burden to the employer under Anderson v. Mt. Clemens Pottery, so the only thing a void removes is the punch's effect on the calculation. enforceWorm() permits voidedAt on the same terms as supersededByPunchId — null to a value, once — and refuses a void with no reason. Un-voiding in place is deliberately impossible: re-instating a punch means inserting one, which leaves its own row and its own explanation. **************** is not a convenience. The unfiltered variant is wrong for deciding clock state: a superseded row keeps its ORIGINAL instant, so a clock-out corrected backwards leaves the dead row latest by occurredAtUtc, and a voided row stays latest until something newer is punched. Either one answers "are you on the clock?" from a record that no longer exists. Sequenced per the entity-change rule: shared-lib installed locally, KI migration written and RUN against 192.168.4.22:5433 with all four ALTERs applied, and only then is this pushed — so no service can build against an unmigrated schema.

  11. · InitializerService

    The void columns on hr_timecard_punch

    Mirrors four new nullable columns on the TimecardPunch entity. A void is neither a delete nor a correction: it says the punch should never have existed — a double tap on the clock, an abandoned break, or the seam between two spans the editor has merged into one. SUPERSEDED_BY_PUNCH_ID cannot express it, because that column means "read this other row instead" and there is no other row. All four are NULLABLE, which is what makes this safe on a populated table: CockroachDB rejects ADD COLUMN ... NOT NULL on a non-empty table and rolls the whole statement back, so a NOT NULL void marker would be logged as a WARN, skipped, and quietly absent. They go FIRST in this runner, ahead of the indexes and REVOKEs it already carries, for the reason @Order(0) exists: the moment shared-lib maps a column, every runner that loads the punch entity emits it in its SELECT, and a column-add ordered late never runs at all — an earlier reader dies on "column does not exist" and takes the initializer down with it. Run against 192.168.4.22:5433, all four applied.

  12. · kamo-internal

    Show the market onboarding panel when no CRM markets exist

    /leads rendered the lead grid with an empty, disabled market tab bar for an org that had never configured a CRM market. It now shows the same onboarding panel /commerce shows for missing commerce markets — extracted to MarketOnboardingEmptyState so the two stay identical — driven by the CRM lead markets from Settings > Client Relationship Manager, not the commerce markets, and pointing at the CRM Markets & Fields tab. The steps and the settings CTA are shown only to members who can manage CRM settings; everyone else gets the existing check-back-later copy.

  13. · kamo-internal

    The period graph, and a timecard you edit as a draft

    Replaces the one-day timeline on **************** with a whole pay period, and changes what editing one feels like. The pills carry no text. They used to carry their own times, and a label is 60px wide however long the thing it describes was — so a ten-minute break and a four-hour shift drew the same pill, and the one property a timeline has that a table does not (width means duration) was the property it threw away. Both times now live in a tooltip that names the start, the end and the span between them, and follows the edge being dragged with a readout that updates as it moves. Corners are 2.5px: enough to take the mechanical hardness off, not enough to round a five-minute pill into a lozenge. The hour header labels every hour, each turned a quarter turn counter-clockwise so all twenty-four fit. Four labels six hours apart cannot tell anyone whether a pill starts at 9 or at 10, which is the only question the header is there to answer now that the pills are unlabelled. Every day in the period gets a row, INCLUDING days that have not happened yet. A graph that only draws days with data cannot show a missing punch, and the missing punch is the most common thing wrong with a timecard: the gap is the finding, and a finding needs a row to appear on. Future days are hatched rather than merely dimmed — "not yet" and "nobody recorded anything" are different facts. Drags snap to the MINUTE. Five-minute snapping could not express 9:02, so a clock that was two minutes out could not be corrected and every corrected time acquired a roundness that read as an estimate. Rounding is a payroll policy the engine applies with neutrality reporting behind it; a slider must not apply a second, invisible one. Arrow keys nudge by one minute, Shift by fifteen, Alt takes one end on its own — a pointer cannot express a minute on a track where a minute is under a pixel. And editing is now a draft. Dragging used to open the mandatory-reason dialog on every release: fixing a day with three wrong punches meant writing three explanations, each a variant of "the clock was wrong", and a real protection under Anderson v. Mt. Clemens Pottery turned into a box people typed "correction" into to make it go away. A protection everyone learns to defeat protects nobody. Edits now stage freely and one explanation is collected on save — still mandatory, still recorded on every punch the save touches, but asked once, when somebody actually knows what they did. Nothing is written until then. Supporting changes: - app/lib/timecardGraph.ts holds every decision the graph makes — pairing, clamping, snapping, the DST-safe instant shift — as pure functions, because a decision about a wage record that can only be checked by dragging something with a mouse is a decision nobody checks. 34 tests. - fetchRange falls back to per-day reads on a 404 so the tab is not broken for whoever loads it between the SecurityService deploy and this one. - the add-a-time field round-trips through local wall-clock. It rendered a UTC slice into a datetime-local input and parsed the result back as local, so every visit to the field moved the time by the zone offset in silence.

  14. · SecurityService

    Relay the editor's period range

    Passthrough for GET /members/{id}/range, the period graph's read. from/to are forwarded only when present so TimecardService can resolve the member's own pay period — forwarding "?from=&to=" would bind to blank strings downstream and silently defeat that default for every org with a pay calendar. Identity is checked with resolveRole and no right, exactly as /day already does. The two requirements pull in opposite directions and a right-based gate gets only one of them: it must not become a way to read a colleague's hours, and it must admit the subject themselves holding NOTHING, because a member in the RESTRICTED tier has no rights at all and still has a statutory right to their own record. TimecardRangeAuthTest covers both directions plus the query building, because this service runs anyRequest().permitAll() with hand-rolled per-handler auth — a mapping that forgets its check is reachable from the public internet through APIService's wildcard forward.

  15. · kamo-internal

    Raise a support conversation when somebody replies in it

    Closing a support window is not leaving the conversation, but it was treated as if it were. ChatNotificationListener returned early on every SUPPORT_TICKET notification — correctly, since a ticket must not open a plain chat window, but it left support with no live re-open at all. Whoever closed their panel, most often the person who asked for help once they had asked, simply never saw the reply. It now opens the support window instead, addressed by the ticket id the notification carries; the tool store dedupes on ticketId, so a window already up is focused rather than duplicated. AutoOpenUnreadSupportChats is the durable half, for a reply that arrived while they were signed out — the live push is core NATS with no replay. Deliberately separate from AssignedTicketOpener: that one answers "this work is now yours", this one answers "somebody said something to you", and for a requestor the first never happens at all. Both acknowledge through the same auto-opened record the chat path uses, so a second device does not replay what the first has shown. ChatsList now takes ticketId straight off the activity frame. Synthesizing a support row without one produced a row that could not be opened, so the list had to throw the frame away and re-read the whole page to get it.

  16. · MediaService

    One conversation, many hands — join on open, name every message, re-open on a reply

    A support ticket is worked by whoever is helping, not only by whoever it is currently stamped with. Assignment decides who the work is offered to and whose list it shows up in; it was never meant to be a lock on the conversation. Opening a ticket now joins its media session. Before, an agent who opened a ticket from the queue that was not assigned to them could type into it — nothing rejected the message — but they were not a member of the session, so the conversation never appeared in their Chats -> Support, no reply ever reached them, and the requestor's "is my agent here" did not count them. They were talking into a room they were not in. Who may join is the same population that can already see the ticket in the queue: the people already in the conversation, plus ACCEPT_SUPPORT_TICKETS or MANAGE_SUPPORT in the assigned org. Everyone else is now refused — this endpoint previously served any ticket in the platform to any authenticated member who could guess a UUID, across organizations, and the join gate would have widened that from reading a stranger's conversation to being written into it. Every message now carries a name. senderName was read straight off Member.usernameAlias, an optional nickname that is unset on almost every member, so it serialized as null and the chat rendered messages with no attribution at all. That is worst in a support ticket, where more than one agent may be answering and the customer has no other way to tell who is talking to them. MemberDisplayNames — already used elsewhere in this controller — falls back to the real name. The same raw read was blanking assigned-agent names, ticket history actors, system-event actors, the agent-availability notice and the reporting table. A closed window no longer means a lost reply. The incoming-message notification carries the ticket id, so the client can raise the conversation it is telling you about, and /sessions/support/auto-open is the durable half for a reply that arrived while you were signed out — the message-side counterpart of assigned-unopened, which only ever covered work changing hands and so never fired for the person who asked for help. Tests: ChatListNativeAliasCaseTest pins the quoting of the feed queries' column aliases, which nothing else can catch — the failure mode is a silent null, not a type error or an exception. SupportAutoOpenHqlTest parses the new query against the real entity model, since Spring Data only validates a @Query at boot and this one lives in the shared library, where a typo takes down every service.

  17. · kamo-internal

    Linkable tabs, a working Add Member, and a full history tab

    Every tab on /account/view/{id} is a real route now — /members, /history and the rest are links somebody can send, bookmark, or land on. Old ?tab= links redirect to the matching one. Each tab loads its own data, so opening an account no longer waits on every tab's data before showing any of it. Add Member opened a "coming soon" toast. It now opens the same picker the Manager field uses, searching every member of the organization rather than only staff — the person paying for an account is frequently not on the payroll. People already on the account stay visible but unpickable with the reason, because a name that is simply missing from a search reads as a broken picker. Set-as-primary and remove work too, remove behind a confirm that says what it does and does not delete. Both the table and the picker mark which kind of person each row is. Without it a member and a team member read identically and the list quietly implies everyone on it works here. The member rows also render at all now: the type claimed keys the endpoint has never sent, so every name showed "---" and nothing could be removed — the row id the remove call needs was one of the invented fields. The lead view's history panel becomes EntityHistoryPanel, shared with the new account History tab: same filters, same paging, same empty states. What stays with each caller is only where the feed comes from and what its ids mean.

  18. · InitializerService

    Create ACCOUNT_HISTORY

    Mirrors the AccountHistory entity in kamo-shared-library. Written explicitly rather than left to ddl-auto because the defaults matter: ID and DATE_CREATED carry database defaults so a plain SQL insert works without naming them, which Hibernate's generated DDL does not do. No foreign keys, on purpose — the log outlives what it describes. An account can be merged away and a member can leave the org without the delete failing or the record of what they did disappearing with them.

  19. · SecurityService

    Account history endpoints, real member rows, an all-members directory

    The account view can now say what has happened to an account and who is on it. - Every mutation on the account surface records history: create, field edits, soft delete, merge (which moves the loser's timeline onto the survivor rather than dropping it), addresses, notes, and the member add/remove/primary paths. GET customers/{uid}/history and /history/filters mirror the lead endpoints request for request, because one component in the internal site reads both. - getCustomerMembers returns what a member row needs to be rendered: the display name, avatar, job title, and isTeamMember. Both kinds of person belong on an account — the one paying is frequently not on the payroll — and without the flag the two read identically. - updateCustomerMembers accepts ids as strings (a unique_rowid() past MAX_SAFE_INTEGER arrives rounded otherwise), clears the primary member when the row being removed is theirs, and no longer mutates the orphanRemoval-mapped collection alongside the repository writes. - The directory search takes scope=all, over every member of the org rather than the staff list, and every row says which kind it is. The endpoint ratchet learns denyUnless: it loads the session and returns 401 without one, or 403 without the right, so a handler that opens with it has done strictly more than resolve a caller. Twenty baseline entries were only listed because the scan could not see the guard that was already there.

  20. · kamo-shared-library

    An audit trail for accounts, and a directory that includes non-staff

    ACCOUNT_HISTORY records what happens to an account the way LEAD_HISTORY records what happens to a lead — deliberately the same columns, because the internal site now renders both through one component and a second shape would have forced a second one. - AccountHistory + repository + AccountHistoryService: snapshot/diff over a field registry, plus one-off events for the things that are not field edits (a member joining, a lead or order arriving, a merge). Recording never throws: an audit write that fails must not take down the operation it was auditing. - AccountHistoryFieldRegistry is the single place a tracked field is declared. Running totals are deliberately absent — they move as a side effect of orders, which already get their own events. - LeadService now writes the account's half of a lead link. Attaching a lead to an account is one act in two timelines, and recording it in only one means whichever page you are looking at is the one that does not mention it. A lead moved between accounts writes to both. - CommerceService records ORDER_PLACED / _LINKED / _UNLINKED / _STATUS_CHANGED against the purchasing account. Silent when an order has no purchaser. - **************** the staff-only directory search widened to every member of an org, for pickers that are not choosing a colleague. Kept in step with TeamMemberRepo.searchDirectory term for term, including the LEFT JOIN TeamMember that keeps job title and department searchable for the staff in the results. activeOnly can only speak for rows that have an employment status, so members without one pass through instead of being read as "not employed". - Member.getPhoneExtension: getPhone() flattens to the number, leaving the extension unreachable from outside the class.

  21. · kamo-internal

    Confirm a clock in/out before writing the punch

    The nav clock punched straight off the click. Every other control in that bar is idempotent or trivially undoable; a punch is a wage record that afterwards only an HR admin with a mandatory written reason can correct — and the button sits one pixel from Logout. It now raises TimecardPunchConfirmDialog, built to the standard of NewSupportRequestPopup and deliberately not a copy of it: that card is an alert that interrupts, so it breathes, refuses its backdrop and arms its buttons late. This one was asked for by a click, so it arms at once and closes on Escape and on the backdrop. Green for in, amber for out, because the two outcomes have to be told apart before a word is read. Its centrepiece is a clock ticking to the SECOND, and the copy says outright that the time recorded is the moment of confirmation rather than the moment on screen while deciding — the point being that hesitating is free and nothing is written until you say so. The first tick is aligned to the second boundary, so the readout agrees with every other clock on the member's screen instead of landing wherever the click did. Behaviour that changed with it: - the idempotency key is minted when the dialog OPENS and reused for a retry. Per-attempt keys were the bug this prevents: a request that timed out may well have landed, and retrying under a fresh key is a second punch on a wage record. - the action is frozen at open time, so a 60s poll flipping the clock underneath the card (a punch from their phone) cannot change the question mid-answer. - the optimistic flip is gone. It existed because the punch fired off the click; the member now watches a card that stays up until the server answers, so a guessed state would only be a chance to show the wrong one. - a failed punch holds the card open and says so. Silently reverting an icon is no way to learn about a punch that did not happen — that surfaces days later as a missing-punch exception on the timecard. - switching identity (god-mode sudo, session switch) drops an open confirmation, which would otherwise write the previous member's punch against the new one.

  22. · kamo-internal

    Segmented status filter in place of the select

    Pending/Complete/All are three short peers that fit on one line, so a dropdown hid two of them behind a click and spent a label and a 170px well to say nothing. A closed select also cannot answer "am I looking at everything?" without opening it. SegmentedFilter is shared: exclusive ToggleButtonGroup, accent taken as a CSS var so each surface keeps its identity, and the null that exclusive mode hands back when the selected segment is re-clicked is discarded — a filter always has a value. Assigned Training gets it too, in --training-brand: the two panels share one tab strip, and one of them keeping a dropdown would read as two different products.

  23. · kamo-internal

    Slide a crowded nav instead of crushing its options

    Pinning more options than a nav can show squeezed the tiles out of shape: both strips were raw overflow boxes and the tiles are flex children with no flex-shrink of their own, so the top bar distorted them (and hid its scrollbar outright, swallowing options with no sign they existed) while the rail drew a 12px scrollbar over its own gradient. Both navs now render their movable options through NavOverflowScroller: tiles keep their footprint, end arrows and a soft edge fade in only on the side that still has something to reach, and the strip can be paged, held, wheeled, thrown with momentum, or dragged. The current page's option is scrolled back into view on arrival, and the top bar's fixed chrome no longer shares the shortfall with the one child that can absorb it. Dragging an option and sliding the strip are the same gesture on a crowded nav, so a crowded nav puts rearranging behind a short hold. That hold withholds the mousedown from dnd-kit rather than using its `delay` constraint: the delay constraint activates on the timer alone and dnd-kit suppresses the click of any press that reaches activation, which would have stopped every slightly-slow click on a nav option from navigating.

  24. · kamo-marketing

    Index the collection detail pages in 19 more languages

    The sync finished, so the ~2,400 detail pages that were noindexed for having English body copy now have real translations and can be indexed. Measured across all 21 locales: every one lands between 0.0% and 8.8% of collection values identical to English, except sw and ta at exactly 100.0% — those two have no provider, so TranslateService returned the source text and the run wrote English back under the appearance of success. COLLECTION_CONTENT_LOCALES is therefore derived, not listed: every advertised locale minus the no-provider set. sw keeps its noindex on detail pages only — its base bundle is genuine Swahili, so its static pages stay indexed — and ta gets no robots directive at all, because it already canonicals to the English original and stacking noindex on top would send two contradictory signals about one piece of content.

  25. · kamo-shared-library

    Per-member overdue rollup for the compliance matrix

    The HR Overview's matrix branches on `row.overdue > 0`, but the legal and training rollups shipped with that field hardcoded to 0 — a branch that could never be taken, so those two domains could only ever show amber or green and "sorted worst-first" degenerated. The (member, status) histogram cannot answer it: it carries no dueAt. So each repository gains a grouped count whose predicate is character-for-character the one in countOverdueForOrg, plus GROUP BY teamMemberId. Sharing the predicate is what makes the per-member figures sum to the org-level count rendered above them. Query methods only. No entity change, no DDL, version stays 1.5.0. Callers keep owning the open set, and the two are NOT the same: legal passes OPEN_STATES, training passes REMINDABLE_STATES. AWAITING_GRADING is open but owes the member nothing, and counting it overdue would reproduce the legal module's AWAITING_OTHERS bug that told on-time members they were weeks late, forever.

  26. · kamo-internal

    Build the Overview tab as a report over the other four

    Full-width reporting console: masthead readout, four tab pillars, the needs-attention ledger, the workforce compliance matrix and an activity rail. Read-only — every action is a deep link into the owning tab.

  27. · kamo-internal

    Overview attention ledger, compliance matrix and activity rail

    useMemberNames sources /api/security/members/names rather than the brief's guessed .../org-config shape: org-config never returns a member list (confirmed by reading LegalPackageDialog.tsx and TrainingAssignDialog.tsx, which parse only departments/jobTitles from it), while /members/names is the endpoint already built for id-to-name resolution and already used the same way by mortgageAppsApi.memberNames().

  28. · kamo-internal

    Overview masthead and the four tab pillars

  29. · kamo-internal

    Overview data hook and shared visual primitives

    useHrOverview fetches attendance + docs summaries with allSettled so one dead service degrades only its own sections; it skips fetches entirely for sections visibleOverviewSections() says the member cannot see. The primitives (CountUp, ProgressRing, StackedBar, StatePip, Figure, SectionShell) are the shared building blocks the overview sections render with, all motion guarded by useReducedMotion and EASE_HOUSE.

  30. · kamo-internal

    Overview summary clients and the rules they render from

Your universe is waiting.

Start free in minutes. Add products whenever you are ready — no migration, no new logins.

Free forever — no credit card required