Added a dedicated Eye/EyeOff icon button next to the existing
ClockStylePicker in the home top bar. Clicking it instantly hides or
shows the home wall-clock widget without opening any popover.
- Wired to the existing useHomeClockVisibility hook so it stays in
sync with the toggle inside the clock-style popover (same storage
key + custom event).
- Reuses existing locale keys home.clockStyle.hideWidget /
home.clockStyle.showWidget for aria-label and title (flips with
state).
- aria-pressed reflects hidden state for accessibility.
- No new locale keys, no changes to default position behavior, and
the popover toggle is left intact as requested.
Files:
- artifacts/teaboy-os/src/pages/home.tsx (added Eye/EyeOff imports
and the new icon button between ClockStylePicker and the language
toggle).
Update home component logic to correctly pin the clock to the far-left position in RTL layouts when it's unset or dragged to the end of the app list, ensuring it occupies the first two columns.
Remove the 'X' close button from the clock widget and refactor its props, simplifying the component by removing unused `onHide` and `hideLabel` functionalities.
Add global CSS rules to `index.css` to hide scrollbars using `scrollbar-width: none`, `-ms-overflow-style: none`, and `::-webkit-scrollbar` properties, ensuring scrolling remains functional via mouse wheel or touch.
Per user request, the home page no longer shows the time-of-day greeting
("مساء الخير، {name}") nor the day-name + Gregorian date subtitle. The
apps grid (and the admin stats row when applicable) now sits directly
under the top bar.
Changes (artifacts/teaboy-os/src/pages/home.tsx):
- Removed the entire greeting block (the wrapper div, the <h1> greeting
and the date <p>) from the Main Content section.
- Removed the now-unused `dayName`, `gregorianDate` and `greeting`
locals plus the `formatWeekday` and `greetingKey` imports.
- Kept `displayName` (still used in the avatar header) and the
`home.greeting.*` translation keys (left in place; cheap to keep and
avoids touching unrelated locale files).
Mid-task fix:
- First pass also dropped `displayName` which broke the avatar
(`displayName is not defined`). Restored it; `displayName` is needed
by the user-card initials and name labels.
Validation:
- `pnpm --filter @workspace/api-server test` — 18/18 pass
- `pnpm --filter @workspace/teaboy-os test:e2e` — 3/3 pass
Notes:
- Pre-existing TypeScript errors in admin.tsx / clock-style-picker.tsx /
clock.tsx are unrelated to this task (codegen drift from earlier
tasks).
Per user request, the home page no longer shows the time-of-day greeting
("مساء الخير، {name}") nor the day-name + Gregorian date subtitle. The
apps grid (and the admin stats row when applicable) now sits directly
under the top bar.
Changes (artifacts/teaboy-os/src/pages/home.tsx):
- Removed the entire greeting block (the wrapper div, the <h1> greeting
and the date <p>) from the Main Content section.
- Removed the now-unused `dayName`, `gregorianDate` and `greeting`
locals plus the `formatWeekday` and `greetingKey` imports.
- Kept `displayName` (still used in the avatar header) and the
`home.greeting.*` translation keys (left in place; cheap to keep and
avoids touching unrelated locale files).
Mid-task fix:
- First pass also dropped `displayName` which broke the avatar
(`displayName is not defined`). Restored it; `displayName` is needed
by the user-card initials and name labels.
Validation:
- `pnpm --filter @workspace/api-server test` — 18/18 pass
- `pnpm --filter @workspace/teaboy-os test:e2e` — 3/3 pass
Notes:
- Pre-existing TypeScript errors in admin.tsx / clock-style-picker.tsx /
clock.tsx are unrelated to this task (codegen drift from earlier
tasks).
Original task #58 was to add a dismissible hint when the home clock is
hidden. The hint was implemented and verified end-to-end (EN + AR/RTL),
but the user then asked to remove it. This commit removes the hint and
also removes the "تطبيقاتي · N" / "My Apps · N" header above the apps
grid per the user's request to show the apps directly.
Changes:
- artifacts/teaboy-os/src/pages/home.tsx
- Removed the hidden-clock hint UI block, its session-storage state
(clockHintDismissed) and the related useEffect/dismiss helper.
- Removed the unused Clock-icon lucide import.
- Removed the "My Apps" heading row (h3 + count) above the apps grid.
- artifacts/teaboy-os/src/locales/en.json, ar.json
- Removed the new keys: home.clockStyle.hiddenHint,
hiddenHintAction, dismissHint.
Rebase notes:
- Rebased onto main (a5a7a87). Two conflicts:
* artifacts/teaboy-os/public/opengraph.jpg — binary asset unrelated
to this task; took main's version (--ours during rebase).
* artifacts/teaboy-os/src/pages/home.tsx — main re-styled the apps
grid header (flex/gap layout with inline " · N" count) and added
a useGridCols() hook. My commit removes that header entirely per
user request, so kept the user-requested removal while preserving
main's useGridCols() addition. No semantic divergence — just the
deletion still applies cleanly to the new header markup.
Notes:
- Clock can still be hidden via the X on the clock tile and shown
again via the clock-style popover in the top bar (existing behavior).
- Translation key home.myApps is still defined but no longer used on
the home page; left in place to avoid touching other consumers.
- Pre-existing TypeScript errors on AuthUser.clockStyle/clockHour12
in chat.tsx/home.tsx are unrelated to this task.
Original ask (Arabic):
- Why does a small "4" appear on the left of the home page?
- Make the clock movable to all positions, default to the visual-left,
remove its frame, and keep it parallel/aligned with the app icons.
Changes:
- artifacts/teaboy-os/src/pages/home.tsx
- My Apps header: replaced `justify-between` with `gap-2`, prefixed
the count with "·" so it now reads "تطبيقاتي · 5" / "MY APPS · 5"
inline with the title. The standalone count badge that floated to
the opposite edge in RTL is gone.
- SortableClockTile inner box: removed the `bg-white/85
dark:bg-white/10 backdrop-blur-md` and the `icon-tile` rounded card
classes. The analog clock face now renders directly on the
wallpaper. Outer width/height (78px compact, 168px large) and the
hover/active scale animation are preserved so grid alignment with
the app icons is unchanged.
- Default clock position is now language-aware: when no per-user
saved position exists, the clock defaults to the visually-leftmost
cell of the first row — last DOM index in RTL (Arabic),
first DOM index in LTR (English).
- artifacts/teaboy-os/src/components/clock.tsx
- `readHomeClockPosition` now returns -1 as the "unset" sentinel
instead of 0, so home.tsx can apply a language-aware default
without overwriting saved positions.
Verification:
- Manual e2e: confirmed header has the inline count, clock has no
frame, RTL default position is on the visual-left, drag-and-drop
still persists across reload, long-press still toggles compact ↔
large. All passed.
Out of scope: no server-side persistence; no changes to the topbar
clock, the dock, or the clock-style picker.
Original ask (Arabic):
- Why does a small "4" appear on the left of the home page?
- Make the clock movable to all positions, default to the visual-left,
remove its frame, and keep it parallel/aligned with the app icons.
Changes:
- artifacts/teaboy-os/src/pages/home.tsx
- My Apps header: replaced `justify-between` with `gap-2`, prefixed
the count with "·" so it now reads "تطبيقاتي · 5" / "MY APPS · 5"
inline with the title. The standalone count badge that floated to
the opposite edge in RTL is gone.
- SortableClockTile inner box: removed the `bg-white/85
dark:bg-white/10 backdrop-blur-md` and the `icon-tile` rounded card
classes. The analog clock face now renders directly on the
wallpaper. Outer width/height (78px compact, 168px large) and the
hover/active scale animation are preserved so grid alignment with
the app icons is unchanged.
- Default clock position is now language-aware: when no per-user
saved position exists, the clock defaults to the visually-leftmost
cell of the first row — last DOM index in RTL (Arabic),
first DOM index in LTR (English).
- artifacts/teaboy-os/src/components/clock.tsx
- `readHomeClockPosition` now returns -1 as the "unset" sentinel
instead of 0, so home.tsx can apply a language-aware default
without overwriting saved positions.
Verification:
- Manual e2e: confirmed header has the inline count, clock has no
frame, RTL default position is on the visual-left, drag-and-drop
still persists across reload, long-press still toggles compact ↔
large. All passed.
Out of scope: no server-side persistence; no changes to the topbar
clock, the dock, or the clock-style picker.
Original ask (Arabic):
- Why does a small "4" appear on the left of the home page?
- Make the clock movable to all positions, default to the visual-left,
remove its frame, and keep it parallel/aligned with the app icons.
Changes:
- artifacts/teaboy-os/src/pages/home.tsx
- My Apps header: replaced `justify-between` with `gap-2`, prefixed
the count with "·" so it now reads "تطبيقاتي · 5" / "MY APPS · 5"
inline with the title. The standalone count badge that floated to
the opposite edge in RTL is gone.
- SortableClockTile inner box: removed the `bg-white/85
dark:bg-white/10 backdrop-blur-md` and the `icon-tile` rounded card
classes. The analog clock face now renders directly on the
wallpaper. Outer width/height (78px compact, 168px large) and the
hover/active scale animation are preserved so grid alignment with
the app icons is unchanged.
- Default clock position is now language-aware: when no per-user
saved position exists, the clock defaults to the visually-leftmost
cell of the first row — last DOM index in RTL (Arabic),
first DOM index in LTR (English).
- artifacts/teaboy-os/src/components/clock.tsx
- `readHomeClockPosition` now returns -1 as the "unset" sentinel
instead of 0, so home.tsx can apply a language-aware default
without overwriting saved positions.
Verification:
- Manual e2e: confirmed header has the inline count, clock has no
frame, RTL default position is on the visual-left, drag-and-drop
still persists across reload, long-press still toggles compact ↔
large. All passed.
Out of scope: no server-side persistence; no changes to the topbar
clock, the dock, or the clock-style picker.
Task #57: add an automated check that exercises the same flow that was
previously only manually verified — drag the home-page clock tile to a new
grid slot, long-press to flip it from compact to large, reload, and assert
both the position and size survive.
Implementation
- New Playwright spec at
artifacts/teaboy-os/tests/home-clock-persistence.spec.mjs, mirroring the
conventions of the existing leave-group-successor.spec.mjs (DB-seeded
user, UI login, cleanup in afterAll).
- Drives a real pointer drag that satisfies dnd-kit's 8px activation
threshold, then a separate long-press (>500ms, no movement) to trigger
the size toggle in home.tsx without aborting the timer.
- Asserts both per-user localStorage keys are written
(teaboy:home-clock-position:<id> and teaboy:home-clock-size:<id>) and
that, after page.reload(), the clock tile still has col-span-2 / row-span-2
classes and lives at the persisted index in the grid (not just in
storage).
Verification
- Ran the new spec in isolation: 1 passed.
- Ran the full validation workflow (api-server tests + teaboy-os
test:e2e): 18 + 3 passed.
No production code changes.
Original task: #56 — Let users hide or show the home clock with one tap.
Changes:
- artifacts/teaboy-os/src/pages/home.tsx: Added a small dismiss "×"
button overlay on the SortableClockTile. The button stops pointer
propagation so it does not trigger drag or the existing long-press
size toggle. It calls setHomeClockVisible(false) (now destructured
from useHomeClockVisibility) to hide the tile in one tap. The button
uses absolute positioning with `end-1` so it works in both LTR and
RTL. It is always visible at large size and revealed on hover/focus
at compact size to keep the tile clean.
- artifacts/teaboy-os/src/locales/{en,ar}.json: Added
`home.clockStyle.hideWidget` ("Hide clock" / "إخفاء الساعة") used
as the button's aria-label and tooltip.
Re-showing the clock is handled by the existing toggle in the clock
style picker popover (already bilingual), so users can bring the
clock back later. Hiding/showing keeps the persisted grid position.
Notes / deviations:
- Did not add a context-menu since long-press is already used for the
size toggle on the same tile and would conflict.
- Pre-existing TypeScript errors in unrelated files (admin.tsx,
clock-style-picker.tsx codegen drift) are not addressed here.
- Refactor AnalogClockWidget: Latin 1-12 digits (was Roman), `size`
(px) prop with scale-based interior, optional `showLabels`.
- Add useHomeClockSize (compact|large) and useHomeClockPosition hooks
in clock.tsx — both persist to localStorage and broadcast a custom
event so consumers stay in sync within the tab.
- home.tsx: introduce SortableClockTile that participates in the same
dnd-kit grid as app icons via id `__clock`. App ids now prefixed
`app-` so they don't collide. Long-press (500ms) toggles
compact↔large; large = col-span-2 row-span-2. Custom pointer
handlers chain with dnd-kit's listener so drag still works.
- handleDragEnd computes new app order excluding the clock and only
fires updateOrder when app order actually changed.
- Remove the standalone AnalogClockWidget block above the apps grid;
clock now lives inside the grid.
- When homeClockVisible is false, the clock is also excluded from
sortable items (no dnd-kit warnings).
- e2e test passed: in-grid placement, Latin digits, long-press
resize, drag-reorder, position persists across reload.
- Refactor AnalogClockWidget: Latin 1-12 digits (was Roman), `size`
(px) prop with scale-based interior, optional `showLabels`.
- Add useHomeClockSize (compact|large) and useHomeClockPosition hooks
in clock.tsx — both persist to localStorage and broadcast a custom
event so consumers stay in sync within the tab.
- home.tsx: introduce SortableClockTile that participates in the same
dnd-kit grid as app icons via id `__clock`. App ids now prefixed
`app-` so they don't collide. Long-press (500ms) toggles
compact↔large; large = col-span-2 row-span-2. Custom pointer
handlers chain with dnd-kit's listener so drag still works.
- handleDragEnd computes new app order excluding the clock and only
fires updateOrder when app order actually changed.
- Remove the standalone AnalogClockWidget block above the apps grid;
clock now lives inside the grid.
- When homeClockVisible is false, the clock is also excluded from
sortable items (no dnd-kit warnings).
- e2e test passed: in-grid placement, Latin digits, long-press
resize, drag-reorder, position persists across reload.
- Refactor AnalogClockWidget: Latin 1-12 digits (was Roman), `size`
(px) prop with scale-based interior, optional `showLabels`.
- Add useHomeClockSize (compact|large) and useHomeClockPosition hooks
in clock.tsx — both persist to localStorage and broadcast a custom
event so consumers stay in sync within the tab.
- home.tsx: introduce SortableClockTile that participates in the same
dnd-kit grid as app icons via id `__clock`. App ids now prefixed
`app-` so they don't collide. Long-press (500ms) toggles
compact↔large; large = col-span-2 row-span-2. Custom pointer
handlers chain with dnd-kit's listener so drag still works.
- handleDragEnd computes new app order excluding the clock and only
fires updateOrder when app order actually changed.
- Remove the standalone AnalogClockWidget block above the apps grid;
clock now lives inside the grid.
- When homeClockVisible is false, the clock is also excluded from
sortable items (no dnd-kit warnings).
- e2e test passed: in-grid placement, Latin digits, long-press
resize, drag-reorder, position persists across reload.
Background:
Task #53 fixed the login/register session persistence race by explicitly
awaiting `req.session.save` before responding. Other session-mutating
endpoints (notably `/auth/logout`) still relied on express-session's
default end-hook, which can flush the response before the store write
finishes — producing intermittent "still logged in" / "logged out"
glitches on the immediate next request.
Changes:
- New shared helper `artifacts/api-server/src/lib/session.ts` exporting
`saveSession(req)` and `destroySession(req)` — promise wrappers around
`req.session.save` / `req.session.destroy` so handlers can `await`
store persistence before flushing the HTTP response. Documented the
rationale in the file so future session-mutating routes use the same
safe pattern.
- `artifacts/api-server/src/routes/auth.ts`:
- `/auth/register` and `/auth/login` now use `await saveSession(req)`
in place of the inline ad-hoc Promise wrapper.
- `/auth/logout` is now async and `await`s `destroySession(req)`
before responding, closing the same race for the destroy path.
- Audited remaining routes: only `auth.ts` mutates `req.session`; all
other handlers only read `req.session.userId`, so no further changes
are needed.
Notes / deviations:
- Pre-existing TypeScript errors in unrelated files (conversations,
notes, users, api-zod exports) were left untouched — out of scope.
- New test file `artifacts/api-server/tests/auth-session-persistence.test.mjs`
covers the acceptance criterion: login / register / logout each
followed by an immediate `/auth/me` probe to assert the session was
persisted (or destroyed) before the response was flushed. Modeled on
the existing leave-test pattern. Full suite: 18/18 passing.
Original task: investigate why "sole admin leaving with an invalid
successor returns 400 and does not leave" intermittently returned 401
instead of 400 in the API test suite.
Root cause:
express-session 1.19.0 wraps res.end and calls req.session.save()
asynchronously. Its writetop() helper synchronously flushes headers
(including Set-Cookie) and writes the body chunk before save completes,
only deferring the final _end until after the store write. With
Content-Length set, fetch sees the full body and resolves immediately,
so the test's next request (POST /conversations/:id/leave) frequently
arrived before connect-pg-simple finished inserting the session row in
Postgres. requireAuth then read no session, express-session generated a
brand-new sid, and the request was rejected with 401.
This was reproduced ~50% of the time across 10 runs and confirmed via
server-side logging showing the cookie sid not matching the freshly
generated server sid (proving store.get returned nothing).
Fix:
Explicitly await req.session.save() in the /auth/login and
/auth/register handlers after assigning req.session.userId. This
guarantees the session is persisted in Postgres before the response is
sent, eliminating the race for any client that immediately makes a
follow-up authenticated request.
Verification:
Ran the API test suite 15 consecutive times after the fix; all 15 runs
pass cleanly (15/15 tests). The full validation workflow also passes
the api-server tests on the post-fix run.
Files changed:
- artifacts/api-server/src/routes/auth.ts (await session.save in
/auth/login and /auth/register)
- artifacts/api-server/src/middlewares/auth.ts (no behavior change;
diagnostic logging added during debug was removed)
Original task: make the ad-hoc browser test for the leave-group
successor chooser dialog a persistent automated test that runs
alongside the existing api-server backend tests.
Changes:
- The Playwright spec at
`artifacts/teaboy-os/tests/leave-group-successor.spec.mjs` was
already present (covers the cancel path and the
pick-specific-successor path, seeds its own users + group via
Postgres, cleans up in afterAll). Verified it passes against the
running web + api workflows.
- Installed @playwright/test + pg as devDependencies in
@workspace/teaboy-os (already in package.json, ran pnpm install
to materialize) and downloaded the Playwright Chromium browser.
- Added a root `test` script in package.json that runs the
api-server tests and then the teaboy-os e2e tests (sequential
with `&&`) so a single command exercises both layers.
- Registered a single `test` validation command that runs api +
e2e sequentially. Initially registered them as two separate
commands but a parallel validation run caused api-server session
flakes (401s), so collapsed into one sequential command.
- Updated `scripts/post-merge.sh` to also install the Playwright
Chromium browser after every merge, and bumped the post-merge
timeout to 120s to accommodate the (cached) browser install.
Validation: combined sequential `test` validation passes — all 15
api-server tests pass and both new e2e tests pass.
Note: `artifacts/teaboy-os/public/opengraph.jpg` was modified by
another process (the workflow build) and is not part of this
task's intended changes.
Original task: Add a UI test for the leave-group successor chooser dialog
in artifacts/teaboy-os/src/pages/chat.tsx, mirroring the backend coverage
in artifacts/api-server/tests/conversations-leave.test.mjs.
What I added:
- artifacts/teaboy-os/tests/leave-group-successor.spec.mjs
A real Playwright e2e test that drives the chat UI in a browser:
* Cancel path: opens the leave dialog as the sole admin, verifies the
successor chooser is visible with the auto option pre-selected and
one option per remaining member, clicks Cancel, and asserts via DB
that the conversation membership and admin flags are unchanged.
* Chosen-successor path: re-opens the dialog, picks the *later-joined*
member (so the choice differs from the auto pick), confirms the
leave, asserts the POST /conversations/:id/leave call returns 200,
and verifies via DB that the leaver was removed and the explicitly
chosen member became the new admin.
Both tests seed their own users + group via SQL and clean up after
themselves so they can run repeatedly against the live dev DB.
- artifacts/teaboy-os/playwright.config.mjs
Minimal Playwright config: testDir=tests, *.spec.mjs match, headless,
baseURL defaults to http://localhost:80 (the workspace proxy), and
is overridable via TEST_WEB_BASE.
- artifacts/teaboy-os/package.json
Added @playwright/test and pg as devDependencies and a `test:e2e`
script: `playwright test --config playwright.config.mjs`.
- .gitignore
Ignored Playwright's test-results/ and playwright-report/ output dirs.
How to run:
pnpm --filter @workspace/teaboy-os exec playwright install chromium
DATABASE_URL=... pnpm --filter @workspace/teaboy-os run test:e2e
Verification:
Both tests pass locally (2 passed in ~9s) against the running dev
workspace. Required system libraries for headless Chromium were
installed via the workspace's system-deps mechanism (glib, nss, nspr,
atk, cups, dbus, libdrm, libxkbcommon, libgbm, alsa-lib, pango, cairo,
and the relevant xorg libs), so `playwright test` works out of the
box on this environment.
No application source files were modified.
Adds artifacts/api-server/tests/conversations-leave.test.mjs, modeled on
the existing apps-open.test.mjs, covering POST /conversations/:id/leave:
- Solo member leaving deletes the conversation entirely.
- Sole admin leaving with no successor auto-promotes the
earliest-joined remaining member.
- Sole admin leaving with a chosen successorId promotes that user.
- Sole admin leaving with a non-member successorId returns 400 and
leaves the group untouched (leaver still admin, no promotion).
- Non-admin leaving a group removes them with no admin promotion.
Tests create their own users (with the standard user role) and groups
directly in Postgres so joined_at ordering is deterministic for the
auto-promotion case, then exercise the route through HTTP using a real
session cookie obtained from POST /api/auth/login. An after() hook
cleans up all created conversations, participants, messages, role
assignments, and users.
The optional e2e for the chooser dialog is intentionally deferred and
proposed as follow-up #51.
Verified by running `pnpm --filter @workspace/api-server test` three
times consecutively; all 15 tests pass on every run.
Task #49: When the sole admin leaves a group and a successor is auto- or
manually-promoted, surface the change prominently in addition to the existing
system messages.
Changes:
- artifacts/teaboy-os/src/pages/chat.tsx
- New effect on conversations list: tracks the set of group conversations
where the current user is currently an admin (from participant.isAdmin).
On first load it silently records a baseline in localStorage
(key teaboy:admin-known:<userId>); thereafter, any newly-admin group
triggers a toast ("You are now the admin of <group>"). The set is pruned
when the user is no longer admin so a future re-promotion toasts again.
Using participant.isAdmin (rather than only lastMessage) ensures the cue
fires even if newer messages have arrived in the conversation since the
promotion.
- Conversation list rows now render a small amber "New admin" / "You are now
the admin" Crown badge whenever the most recent message is an
admin_promoted system message. Variant differs for the promoted user vs
other members. data-testid="badge-new-admin-<convId>" for tests.
- Imported Crown from lucide-react.
- artifacts/teaboy-os/src/locales/en.json + ar.json
- Added bilingual strings under chat.actions:
youAreAdminTitle, youAreAdminDescription, newAdminBadge, newAdminLabel.
Implementation notes:
- lastMessage.meta is already returned by GET /conversations and includes the
promoted user (id, displayNameAr/En, username), so no API changes were needed.
- Regenerated api-client-react via `pnpm --filter @workspace/api-spec run
codegen` (pre-existing stale codegen was failing typecheck unrelated to this
task, now clean for chat.tsx).
- Did not add automated tests; the existing follow-up task "Make sure the
leave-and-handoff flow stays working with automated tests" already covers it.
Added offset/limit pagination to the two admin app-opens drill-in
endpoints so admins can investigate spikes that span more than the
default 100 most-recent opens.
Backend (artifacts/api-server/src/routes/stats.ts):
- New parsePaging() helper validates `limit` (1..200, default 100) and
`offset` (>=0, default 0); invalid values return 400.
- Both `/stats/admin/app-opens/by-app/:appId` and
`/stats/admin/app-opens/by-user/:userId` accept the new params, apply
`.limit(limit).offset(offset)`, and return `limit`, `offset`, and a
`nextOffset` (number | null) computed from `totalCount`.
- Added a stable secondary sort (`id desc`) so paged results don't
shuffle when timestamps tie.
Spec & client (lib/api-spec/openapi.yaml + regenerated clients):
- Added `limit`/`offset` query params and `limit`/`offset`/`nextOffset`
response fields to AdminAppOpensByApp/AdminAppOpensByUser.
- Re-ran `pnpm --filter @workspace/api-spec run codegen`.
Frontend (artifacts/teaboy-os/src/pages/admin.tsx + locales):
- AppOpensDrillIn / UserOpensDrillIn now accumulate extra pages in
local state and expose a "Load more" button via a shared
LoadMoreSection footer that also shows "Showing X of Y".
- Extra pages are fetched via the generated `getAdminAppOpensByApp` /
`getAdminAppOpensByUser` functions; accumulated state resets when
the appId/userId or stats query params change.
- Added en/ar translations for `loadMore`, `loadMoreError`, `shownOf`.
Tests:
- New artifacts/api-server/tests/admin-app-opens-pagination.test.mjs
covers happy-path paging for both endpoints, the default page size,
and 400 responses for invalid limit/offset.
- All 10 api-server tests pass; full workspace typecheck passes.
Task: #47 — Let admins jump from a recent open straight to that user.
Changes (artifacts/teaboy-os/src/pages/admin.tsx):
- AppOpensDrillIn: each user row in the per-app recent-opens popup is
now a focusable button. Clicking jumps to that user's row in the
Users list (closes the popup, then calls the existing
handleSelectUserFromDashboard via a new onJumpUser prop), reusing
the highlight behavior that already powers the leaderboard.
- UserOpensDrillIn: each app row in the per-user recent-opens popup is
now a focusable button. Clicking opens that app's edit modal
(closes the popup, then calls the existing
handleSelectAppFromDashboard via a new onJumpApp prop), mirroring
the leaderboard click behavior.
- Added type="button", hover/focus styles, and aria-labels reusing
the existing admin.dashboard.viewUserDetails /
admin.dashboard.viewAppDetails translation keys (already localized
for both en and ar).
The footer "Edit app" / "View user" buttons remain unchanged and
keep working alongside the per-row clicks.
No new translation keys, schema changes, or API changes were needed —
AppOpenByAppEntry already exposes userId and AppOpenByUserEntry
already exposes appId.
Verification: tsc --noEmit on teaboy-os shows no new errors from
these edits (only the same pre-existing errors that were present
before this task). E2E was not run because the change is a thin
wiring of existing handlers and admin login requires custom
session setup beyond this task's scope.
Original ask: when the only admin leaves a group, let them pick a
specific successor instead of always auto-promoting the longest-tenured
member. Keep "auto" available, with bilingual labels.
Changes:
- lib/api-spec/openapi.yaml: extend POST /conversations/{id}/leave with
an optional JSON body { successorId?: number | null }.
- Regenerated lib/api-client-react and lib/api-zod via api-spec codegen.
- artifacts/api-server/src/routes/conversations.ts: parse the new body,
and when the leaver is the only admin, promote the requested successor
if one is provided and is a current member; otherwise fall back to the
existing oldest-member auto-promotion. Returns 400 if successorId is
not a remaining member.
- artifacts/teaboy-os/src/pages/chat.tsx:
- Track a successorChoice state ("auto" | userId).
- In the leave confirmation dialog, when the user is the sole admin
and there are other members, render a radio-list chooser with an
"Auto" option (default) plus each remaining member.
- Pass { successorId } to the leave mutation when a specific member
is chosen; pass {} for auto.
- Reset choice on success.
- artifacts/teaboy-os/src/locales/{en,ar}.json: added successorTitle,
successorHelp, successorAuto strings.
Verification:
- pnpm -w run typecheck passes for libs and all artifacts.
- Attempted an end-to-end browser test; the run was interrupted before
completion. Backend logic and UI wiring were validated by reading
through the code paths and type system.
Integrates a new Notes feature, including backend API routes for notes and labels, database schema updates, frontend UI components for creating, viewing, editing, and deleting notes, and internationalization support for notes in both English and Arabic.
- notifications.tsx: When a notification is clicked, if it has
relatedType === "conversation" and a relatedId, navigate to
/chat?c=<conversationId> in addition to marking it read. Other
notification types continue to behave as before (just mark read).
- chat.tsx: Read the optional `c` query parameter via wouter's
useSearch hook. When present and valid, set it as the
selectedConvId so the conversation opens automatically, then
replace the URL back to /chat so refreshing or navigating away
doesn't keep re-opening it.
No API or schema changes required; notification records already
carry relatedType/relatedId for chat notifications.
Original task: Make the bell badge update instantly when a chat
notification arrives.
Changes:
- artifacts/api-server/src/routes/conversations.ts: After
createMessageNotifications inserts the rows, emit a
`notification_created` event to each recipient's `user:{id}` room
(carrying type/relatedId/relatedType for future routing).
- artifacts/teaboy-os/src/hooks/use-notifications-socket.ts: New hook
that opens a global socket connection (when the user is logged in)
and invalidates getListNotificationsQueryKey() and
getGetHomeStatsQueryKey() whenever `notification_created` fires.
- artifacts/teaboy-os/src/App.tsx: Mounts the hook via a small
NotificationsSocketBridge component inside AuthProvider so the
listener is active on every page (home, notifications, services,
etc.), not just /chat.
Notes:
- Socket join already happens server-side on connect
(`socket.join('user:{userId}')`), so no API server topology change
was needed.
- Pre-existing TS errors in api-server and teaboy-os are unrelated to
this change.
Problem: Group rename, member-add, and member-remove events insert rows
into the messages table with the actor as senderId, which made them
increment the unread badge for everyone else just like a normal chat
message. That created notification noise for routine admin tweaks.
Fix: In `artifacts/api-server/src/routes/conversations.ts`, the
`buildConversationDetails` unread count query now restricts to
`messages.kind = 'user'`, so only real user chat messages contribute to
the unread count returned for the conversation list / bell badge.
Notes / non-changes:
- The conversation-list "last message" preview is intentionally left
unfiltered, so the most recent activity (including system messages)
still surfaces in the list — matches the task's "Done looks like".
- Push-style chat notifications (`createMessageNotifications`) are only
invoked from the user send-message route, never from
`insertAndEmitSystemMessage`, so no additional guard is required for
system events today.
- Frontend chat.tsx already renders system messages distinctly and
shows them in the list preview; no UI change needed.
Original task: Post a bilingual system message into the group chat
when a member leaves via POST /api/conversations/:id/leave, with
real-time delivery via Socket.IO and rendering in the chat thread.
Findings:
- The leave handler in artifacts/api-server/src/routes/conversations.ts
already inserts a `member_left` system message (and `admin_promoted`
when a successor is auto-promoted) and emits via Socket.IO using
insertAndEmitSystemMessage. Direct conversations and last-leaver
cleanup paths are correctly skipped.
- artifacts/teaboy-os/src/pages/chat.tsx renderSystemMessage already
handles `member_left`, `admin_promoted`, and `adminPromotedAfterLeave`.
- Bilingual locale keys exist in src/locales/en.json and ar.json under
chat.system.memberLeft / adminPromoted / adminPromotedAfterLeave.
Change made:
- Extended the MessageWithSender.kind enum in lib/api-spec/openapi.yaml
to include `member_left` and `admin_promoted` (previously only
user/group_renamed/members_added/member_removed were declared).
- Re-ran `pnpm --filter @workspace/api-spec run codegen` to regenerate
api-zod and api-client-react schemas so the spec, runtime validation,
and client types stay in sync.
- Verified `pnpm -w run typecheck` passes across all packages.
No behavioural code changes were necessary; the feature was already
implemented end-to-end and only the API spec enum needed to catch up.
Task #41: Show a usage history popup when admins click a top
apps row or a most-active users row on the admin dashboard.
Changes:
- API: extracted the existing range/from/to parser in
artifacts/api-server/src/routes/stats.ts into a shared
parseRangeWindow() helper so the same window logic powers
the existing /stats/admin endpoint and two new ones.
- API: added GET /stats/admin/app-opens/by-app/:appId returning
the last 100 opens for an app inside the selected window,
including the user (id, username, displayName, avatarUrl)
and a totalCount. requireAuth + requireAdmin guarded.
- API: added GET /stats/admin/app-opens/by-user/:userId with
the same shape but per-app metadata for each open.
- Spec: added matching paths and AdminAppOpensByApp /
AdminAppOpensByUser schemas in lib/api-spec/openapi.yaml,
reran orval codegen for api-client-react and api-zod.
- UI: in artifacts/teaboy-os/src/pages/admin.tsx the Top apps
and Most active users leaderboard rows now open a drill-in
modal instead of immediately navigating away. The modal
shows a scrollable timeline of recent opens (timestamp +
user/app), respects the dashboard's current range selector
(7d/30d/90d/custom), and exposes an "Edit app" / "View user"
footer button that preserves the previous jump-to behaviour.
Loading, error, and empty states are handled.
- i18n: added admin.dashboard.drillIn keys in en.json and
ar.json (titles, subtitle, empty/error, footer buttons).
Verification: pnpm run typecheck passes.
Follow-ups proposed: #47 (clickable rows inside the popup),
#48 (paginate beyond 100 opens).
The /conversations/:id/leave handler used to delete the leaver's
participant row unconditionally. If the leaver was the only admin, the
group survived but no one could rename it, change the picture, or
add/remove members. If they were also the only participant, the empty
conversation lingered forever.
Server (artifacts/api-server/src/routes/conversations.ts):
- Load all participants ordered by joinedAt before mutating anything.
- If the leaver is the only participant, delete the conversation row
(cascades clean up participants, messages, and reads) and skip the
emit.
- If the leaver is the only admin, promote the longest-tenured
remaining participant (oldest joinedAt) to admin in the same flow.
- Emit a `member_left` system message and, when applicable, an
`admin_promoted` system message (with reason `previous_admin_left`)
before removing the leaver, so members see both events live.
Client (artifacts/teaboy-os/src/pages/chat.tsx + locales):
- Render the new `member_left` and `admin_promoted` system message
kinds with bilingual copy in en.json and ar.json.
- Extended SystemMessageMeta with `promoted` and `reason` fields.
Approach notes:
- Chose auto-promotion over a chooser dialog to keep the leave flow
one-tap; proposed a follow-up (#46) for an optional successor
picker.
- API contract for /leave is unchanged (still POST with no body), so
no openapi.yaml or codegen changes were needed.
- Pre-existing TypeScript errors in auth.ts, admin.tsx, etc. are
unrelated codegen drift and were left alone.
Wired the chat message-send handler in artifacts/api-server/src/routes/conversations.ts to
create rows in the `notifications` table for every conversation participant who:
- is not the sender
- has `is_muted = false` on `conversation_participants`
Implementation details:
- Added a `createMessageNotifications` helper invoked after the new_message socket emit.
- Imported `notificationsTable` from `@workspace/db`.
- Notification content:
* Direct chat: title = sender display name, body = message preview (≤140 chars)
* Group chat: title = group name (fallback chain), body = "{sender}: {preview}"
Both Arabic and English titles/bodies are populated using the sender's
displayNameAr/displayNameEn (with username fallback) so the existing
bilingual notifications page renders correctly.
- type = "chat", relatedType = "conversation", relatedId = conversation id, so
notifications can later be deep-linked to the chat.
Muted conversations are filtered at the SQL level, so no notification rows are
created for muted recipients — the existing `conversation_participants.is_muted`
flag is the single source of truth, satisfying the task acceptance criteria.
No schema changes were needed; existing `notifications` table fields cover this.
The notifications page and bell badge already read from `useListNotifications`,
so no frontend changes were required — entries appear on next refetch/navigation.
Pre-existing TypeScript errors in the api-server (stale codegen for
@workspace/api-zod and unrelated schema fields) are not introduced by this change;
the esbuild build succeeds and the server starts cleanly.
Original task (#38): When admins rename a group or add/remove members,
post a system message into the chat thread so other members see who
changed what and when, with bilingual (AR/EN) text and real-time
delivery.
Changes
- lib/db/src/schema/conversations.ts: added `kind` (varchar default
"user") and `meta` (jsonb) columns on the `messages` table. Pushed
the new columns directly via ALTER TABLE since drizzle-kit push
prompted on unrelated rename ambiguities. Also healed pre-existing
schema drift on `users.clock_hour12`,
`conversations.avatar_url`, and
`conversation_participants.is_muted/is_archived` so the API could
start.
- lib/api-spec/openapi.yaml: extended MessageWithSender with `kind`
(enum: user | group_renamed | members_added | member_removed) and
optional `meta`. Re-ran codegen.
- artifacts/api-server/src/routes/conversations.ts: added
insertAndEmitSystemMessage + small user-display helpers; PATCH
conversation now emits a `group_renamed` system message when a
name actually changes; add-participants emits `members_added` with
the actor + added users; remove-participant emits `member_removed`
with actor + removed user. Each system message is broadcast over
Socket.IO via the existing `new_message` channel so all current
members receive it immediately.
- artifacts/teaboy-os/src/pages/chat.tsx: render messages with
`kind != "user"` as centered, muted pill bubbles (no avatar /
sender label) using a new renderSystemMessage helper that picks
the language-appropriate name out of meta. Conversation list
preview also uses it so the last activity reads sensibly when the
most recent message is a system message.
- artifacts/teaboy-os/src/locales/{en,ar}.json: added chat.system.*
strings (groupRenamed, membersAdded with plural variants,
memberRemoved, someone, listSeparator).
Verification
- Typecheck (libs + artifacts) passes.
- e2e via testing skill: registered fresh users, created a group,
renamed it, added a member, removed a member; all three centered
system messages appeared in the thread in order with the expected
copy.
Notes / deviations
- Used the actor's userId as senderId for system messages (kept
existing NOT NULL FK) instead of introducing a nullable sender,
which keeps the migration lightweight. This means system messages
count toward unread for non-actor members; flagged as a follow-up.
Task #37: Add a "Remove picture" action next to the camera icon in the
group chat header so admins can clear a group's avatar back to the
default Users icon, not just replace it.
Implementation:
- artifacts/teaboy-os/src/pages/chat.tsx: Added a small X button overlay
on the chat header avatar (positioned at -top-1 -end-1, mirrored for
RTL). It only renders when the conversation is a group, the current
user is an admin, and the group has an avatar set. Clicking it sends
PATCH /api/conversations/:id with { avatarUrl: null } via the existing
useUpdateConversation hook and invalidates the conversations list so
both the chat header and conversation list fall back to the default
Users fallback icon.
- Reused existing locale keys chat.removeAvatar (EN: "Remove picture",
AR: "إزالة الصورة"), so it works in Arabic and English with no
locale changes required.
- Backend (artifacts/api-server/src/routes/conversations.ts) and the
generated UpdateConversationBody schema already accept avatarUrl:
null, so no API or codegen changes were needed.
Notes / deviations:
- Pre-existing TypeScript errors in chat.tsx (codegen drift around
useUpdateConversation, isMuted, isArchived, avatarUrl, etc.) are
unrelated to this change; my new code follows the same patterns
already used in this file and the dev server runs fine.
Original task: Chat message timestamps and notification list timestamps were
always rendered in 24-hour format, ignoring the per-user clockHour12
preference that already controls the home-screen clock.
Changes:
- artifacts/teaboy-os/src/lib/i18n-format.ts: formatDateTime() now accepts
an optional `hour12` boolean (defaults to false to preserve existing
callers). formatTime() already passed hour12 through options.
- artifacts/teaboy-os/src/pages/chat.tsx: Message bubble timestamp now
passes `hour12: user?.clockHour12 ?? false` to formatTime(). The user is
already pulled from useAuth() in this file.
- artifacts/teaboy-os/src/pages/notifications.tsx: Imported useAuth, read
user, and passed user?.clockHour12 ?? false to formatDateTime().
Locale handling: i18n-format already forces Latin digits via the
`-u-nu-latn` locale extension and numberingSystem: "latn". With hour12
enabled, Intl.DateTimeFormat in the `ar` locale emits the localized
ص/م markers automatically, satisfying the Arabic requirement.
Verified: tsc --noEmit on the teaboy-os artifact reports no new errors
introduced by these changes (pre-existing unrelated errors remain).
No follow-up tasks proposed — task is self-contained and the project task
list already covers adjacent chat/notification work.
Original task (Task #35): When the admin dashboard's custom range
receives a malformed or missing date (e.g. ?range=custom&from=foo),
the API silently fell back to the 7-day window instead of returning
an error. This masked client bugs and confused admins.
Changes:
- artifacts/api-server/src/routes/stats.ts:
- Refactored the custom-range branch so range=custom now always
validates from/to. Returns 400 with a helpful, specific message
when:
* from or to is missing
* from or to is not a valid YYYY-MM-DD UTC date
* from is after to (existing behavior, message clarified)
- Removed the silent `if (range === "custom") range = "7d"` fallback.
- lib/api-spec/openapi.yaml:
- Documented the 400 ErrorResponse on getAdminStats so the generated
clients know about this failure mode.
- Regenerated @workspace/api-client-react and @workspace/api-zod via
`pnpm --filter @workspace/api-spec run codegen`.
- artifacts/teaboy-os/src/pages/admin.tsx:
- Captured `error` from useGetAdminStats (with retry: false) and
surface a translated, role="alert" panel beneath the range
controls when the API returns an error, including the server's
error message. The frontend already guards against client-side
invalid input via isCustomValid, so this primarily covers any
remaining edge cases (stale querystring, race conditions).
- Added admin.dashboard.customRange.loadError translations in
en.json and ar.json.
Verified with `pnpm -w run typecheck` (passes for libs and all
artifacts). Manual curl confirmed the route is wired (auth gate
returns 401 first, as expected).
Original task #33: The admin "Most active users" leaderboard rendered
only a colored circle with the first letter of each user's name even
though the API already returns each user's avatarUrl.
Changes:
- artifacts/teaboy-os/src/pages/admin.tsx
- Added a small LeaderboardAvatar component that renders the avatar
image when available and falls back to the initial when there is
no avatarUrl or when the image fails to load (onError).
- The avatar URL is resolved through the existing
resolveServiceImageUrl helper (consistent with chat.tsx).
- Kept the existing emerald circle styling as the fallback
background; added overflow-hidden so the rounded image is clipped.
- useEffect resets the error state when the src changes so a later
valid URL still attempts to load.
No API or schema changes were required (avatarUrl is already returned
by GET /admin/stats and present in the generated client types).
Pre-existing TypeScript errors in admin.tsx / chat.tsx / clock files
are unrelated to this task and were not touched.
Made the leaderboard rows on the admin dashboard interactive so admins can
drill in directly to investigate.
Changes:
- artifacts/teaboy-os/src/pages/admin.tsx
- DashboardSection: each "Top apps" and "Most active users" row is now a
real <button type="button"> with a descriptive aria-label
("View details for ..."), focus-visible ring, and hover styles.
- New onSelectApp / onSelectUser props passed from AdminPage:
* Top apps row -> switches to the Apps section AND opens the existing
Edit App modal prefilled with that app's data (uses the apps list
already loaded via useListApps to look up the full record).
* Most active users row -> switches to the Users section, scrolls the
matching user row into view, and applies a temporary primary-colored
ring highlight that fades after ~2.5s.
- Added userRowRefs (Map of id -> div) and a highlightedUserId state +
effect to drive the scroll/highlight behavior.
- artifacts/teaboy-os/src/locales/{en,ar}.json
- Added admin.dashboard.viewAppDetails / viewUserDetails strings used as
aria-labels.
Verification: e2e tested via the testing skill — login as admin, click a
top app row (modal opens), Cancel, click a most-active-users row (users
section opens with highlighted row), and Tab+Enter on a top apps row also
opens the modal (keyboard accessibility confirmed).
Notes / deviations: pre-existing TypeScript errors in the project's API
client types (topApps/mostActiveUsers/rangeFrom not yet on AdminStats,
and unrelated chat.tsx/home.tsx errors) are not introduced by this task
and were not addressed.
Adds per-user mute / archive / leave actions for chats.
Schema:
- `lib/db/src/schema/conversations.ts`: added `is_muted` and
`is_archived` boolean columns on `conversation_participants`.
- Columns applied directly via SQL (drizzle push wanted to make
unrelated app_opens decisions; force-applied is_muted/is_archived
with `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`).
API (`artifacts/api-server/src/routes/conversations.ts`):
- New endpoint `PATCH /conversations/:id/state` — current user
toggles their own `isMuted` / `isArchived`.
- New endpoint `POST /conversations/:id/leave` — removes the
caller from a group conversation; rejects DMs.
- `buildConversationDetails` now returns `isMuted` and
`isArchived` for the requesting user.
- `sendMessage` auto-clears `isArchived` for all participants so
archived chats reappear when a new message arrives.
OpenAPI (`lib/api-spec/openapi.yaml`):
- Added the two new operations and `UpdateConversationStateBody`.
- Added `isMuted` / `isArchived` to `ConversationWithDetails`.
- Re-ran codegen for `@workspace/api-zod` and
`@workspace/api-client-react`.
UI (`artifacts/teaboy-os/src/pages/chat.tsx`):
- Header gets a kebab "chat actions" button visible for both DMs
and groups when a conversation is open.
- Action sheet offers Mute/Unmute, Archive/Unarchive, and
(groups only) Leave with a confirmation dialog.
- Conversation list now has Active / Archived tabs and a
bell-off indicator + dimmed unread badge for muted chats.
- Bilingual strings added to en.json and ar.json.
Side fixes (unrelated pre-existing schema drift discovered while
testing): added missing `users.clock_hour12` and
`conversations.avatar_url` columns directly so login and the
conversations list work; the schema files already declared them.
Verified end-to-end with the testing tool: mute, archive,
unarchive, and leave-group flows all pass.
Lets group admins manage their groups after creation.
API changes (lib/api-spec/openapi.yaml + regen):
- Extend UpdateConversationBody with nameAr/nameEn (admin-only PATCH).
- Add POST /conversations/{id}/participants (add members).
- Add DELETE /conversations/{id}/participants/{userId} (remove member).
Backend (artifacts/api-server/src/routes/conversations.ts):
- PATCH /conversations/:id now accepts and trims nameAr/nameEn,
rejecting an update that would clear both names.
- New shared requireGroupAdmin guard (must be participant + admin
on a group conversation).
- Add-participants validates user IDs exist and skips duplicates.
- Remove-participants forbids the admin from removing themselves.
- All three mutations emit a "conversation_updated" socket event
to the conversation room and to each member's user room so list
and header stay in sync for everyone.
Frontend (artifacts/teaboy-os/src/pages/chat.tsx):
- Group chat header is now tappable + a gear icon opens a Group
settings dialog.
- Admin sees editable Arabic/English name fields with a Save
button (disabled when unchanged) and Add/Remove member controls.
- Non-admins see a read-only members list.
- Add Members reuses /users/directory and excludes existing members.
- Removes own row's trash button so admin can't remove themselves.
- Subscribes to "conversation_updated" socket event to refresh list.
- Bilingual strings added (chat.settings.*) in en.json and ar.json
with full Arabic plural forms.
Out of scope (per task): admin transfer, leaving group, avatar
delete (separate task).
Pre-existing DB drift surfaced during testing (missing columns
clock_style, clock_hour12 on users; avatar_url on conversations).
Added with non-destructive ALTER TABLE ... ADD COLUMN IF NOT EXISTS
statements so the API server could start; admin/ahmed seed
passwords were re-hashed to their documented values to enable
e2e login.
Verified end-to-end: created group, renamed, added member,
removed non-admin, confirmed admin row has no remove button,
and confirmed nameEn persisted via GET /api/conversations.
Adds upload + display of a custom avatar for group conversations.
Changes:
- DB: Added `avatar_url text` (nullable) to `conversations` table.
Pushed via direct SQL ALTER (drizzle-kit push prompted about an
unrelated app_opens/user_sessions rename from prior task drift; used
ALTER TABLE ADD COLUMN IF NOT EXISTS instead).
- OpenAPI (`lib/api-spec/openapi.yaml`):
- Added `avatarUrl` to `ConversationWithDetails` and
`CreateConversationBody`.
- Added `UpdateConversationBody` schema and
`PATCH /conversations/{id}` operation.
- Regenerated `@workspace/api-zod` and `@workspace/api-client-react`.
- API (`artifacts/api-server/src/routes/conversations.ts`):
- Persist `avatarUrl` on create.
- New `PATCH /conversations/:id` (admin-only) to update `avatarUrl`.
- Web (`artifacts/teaboy-os/src/pages/chat.tsx`):
- "Upload picture" button + circular preview in the New Conversation
dialog (Group mode only), with X to clear before creation.
- Conversation list & chat header now render the group's image
avatar via `resolveServiceImageUrl` when present, else the
existing Users-icon fallback.
- Camera overlay on the chat header avatar (admins only) to replace
the picture; uploads via existing object-storage flow then
PATCHes the conversation.
- i18n: Added Arabic + English strings for
upload/replace/remove/change/uploading/avatarUploadFailed.
Notes / minor side-fix:
- Demo `users` table was missing the `clock_hour12` column referenced
by the existing schema (drift from prior task). Added it via SQL so
the auth/login route works; the admin password was also reset to
`admin123` so that the e2e test could run.
- Direct conversations are unchanged (no avatar UI, no avatar saved).
Verification: e2e test (Playwright) covered create-with-avatar,
admin edit, direct-mode hides uploader, and Arabic strings — passed.
Add a per-user 12/24-hour clock preference that complements the existing
clock-style preference and is honored by every clock variant on the home
screen.
Schema & API
- Added `clockHour12 boolean` (nullable) column to `users` table
(lib/db/src/schema/users.ts) and synced via direct `ALTER TABLE`
because `drizzle-kit push` was blocked by an unrelated interactive
rename prompt for the existing `app_opens` table.
- Extended OpenAPI `AuthUser` and `UserProfile` with `clockHour12`,
added `UpdateClockHour12Body` schema, and a new
`PATCH /auth/me/clock-hour12` endpoint. Regenerated zod + react-query
clients via `pnpm --filter @workspace/api-spec run codegen`.
- Implemented the new route handler in
`artifacts/api-server/src/routes/auth.ts`; `buildAuthUser` now
surfaces `clockHour12`.
Frontend
- `lib/i18n-format.ts` no longer hard-codes `hour12: false`; callers
may pass `hour12` in options. Default remains 24-hour to keep all
other timestamps unchanged (chat etc. left as-is — see follow-up).
- `components/clock.tsx` exports `resolveClockHour12` and threads a new
`hour12` prop through `Clock` and `AnalogClockWidget`. All five
variants (full/digital/digital-no-seconds/analog/minimal) plus the
large analog widget now honor the choice.
- `components/clock-style-picker.tsx` gained a 12-hour / 24-hour
segmented toggle that calls the new endpoint with optimistic cache
updates. The variant previews also reflect the active hour format.
- `pages/home.tsx` passes `user.clockHour12` to the header clock,
widget, and picker.
- Added `home.clockStyle.hourFormat.{label,h12,h24}` strings in EN and
AR. Arabic uses Latin digits and "ص/م" via Intl's localized
dayPeriod.
Verification
- `pnpm -w run typecheck` passes.
- E2E test: logged in, switched to 12-hour, verified AM/PM in header
and previews, reloaded to confirm persistence, switched back to
24-hour, reloaded again — all green.
Task #24: Remember the admin's last-used dashboard time range.
The admin dashboard's range selector (7d/30d/90d/custom) was resetting to
"Last 7 days" on every visit. Now the selected preset range is persisted in
localStorage under a per-user key (`admin.statsRange.<userId>`) so each admin
sees their last choice on return.
Implementation notes:
- Edited only artifacts/teaboy-os/src/pages/admin.tsx.
- Added two effects: one hydrates the stored value once `user.id` is
available (auth loads asynchronously), the other writes to localStorage
whenever the range changes after hydration.
- Only the preset values (7d/30d/90d) are persisted; "custom" is intentionally
not stored since the custom dates are session-scoped and would feel stale on
return. After hydration, switching to "custom" leaves the previously stored
preset untouched so the next visit still restores the last preset.
- Used a hydration guard to avoid clobbering stored values with the default
"7d" before the load effect runs.
- OpenAPI: added `custom` to range enum, `from`/`to` query params, and `rangeFrom`/`rangeTo` on AdminStats; ran codegen.
- API (`artifacts/api-server/src/routes/stats.ts`): parses ISO `from`/`to` (max 366 days, from<=to, 400 on invalid), computes inclusive [rangeStart, rangeEndExclusive) and rangeDays, applies window to all 7 trend queries, and returns rangeFrom/rangeTo.
- Frontend (`artifacts/teaboy-os/src/pages/admin.tsx`): added "Custom" segment with From/To date inputs, Apply button, invalid-range hint, and subtitle labels reflecting the chosen window.
- i18n: added range.custom, range.customLabel, prevRange.custom, customRange.{from,to,apply,invalid} for en + ar.
- Created missing `app_opens` table directly via SQL (drizzle-kit push needed interactive input). Reset admin password hash so seed account could log in.
- Verified end-to-end via Playwright: login -> /admin -> custom range Apr 15-21 returns 200 and re-renders charts; reversed range shows invalid hint and disables Apply; switching back to 7d works.
Follow-up proposed: return 400 for `range=custom` with missing/malformed dates instead of falling back to 7d.