Bring the WorkTrack platform onto main - #1
Open
aminullah-dev wants to merge 144 commits into
Open
aminullah-dev wants to merge 144 commits into
aminullah-dev wants to merge 144 commits into
Conversation
Master specification plus nine derived design documents: product requirements, system architecture (C4 + ADRs), normalized database design with ER diagrams, REST API v1 reference, Android architecture and navigation, web admin console design, security architecture (STRIDE, RBAC catalog, biometrics privacy), offline-first sync strategy, and the P0-P4 development roadmap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
Version catalog, composite build-logic with convention plugins (application, library, compose, feature, hilt, room, jvm), and the pinned Gradle wrapper. minSdk 26 / target+compile SDK 35, Kotlin 2.0.20, AGP 8.5.2, Compose BOM 2024.09, Hilt 2.52, Room 2.6.1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
- core:common — AppResult/AppError taxonomy, ULID generator, injectable time and dispatchers, haversine geo math (with unit tests) - core:model — full domain model (org, employees, attendance, shifts, leave, payroll, announcements, sync state) - core:domain — repository contracts and use cases: sign-in, punch with geofence evaluation (accuracy-credited), leave application with balance checks and half-day math, dashboard aggregation (unit tested) - core:database — Room schema v1: 15 tables incl. append-only punches, outbox queue, and per-resource sync cursors - core:datastore — persisted session (DataStore), no token storage - core:network — Retrofit/kotlinx-serialization API client, RFC 7807 error mapping, token refresh interceptor, connectivity monitor - core:data — repository implementations with offline-first outbox writes and the push/pull sync engine (server-authoritative) - core:sync — WorkManager scheduling: periodic + expedited unique work - core:designsystem — Material 3 theme and shared components Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
- feature:auth — email/password sign-in with field validation - feature:dashboard — today's attendance card, shift, leave balances, announcements - feature:attendance — GPS punch flow with geofence status and mock-location rejection, kiosk QR scanner (CameraX + ML Kit), monthly attendance history - feature:leave — balances/requests overview, apply flow with half-days and date pickers, approver inbox with reject-note dialog - feature:payslips — yearly list and earnings/deductions detail - feature:profile — roles, sync health, manual sync, sign out - app — Hilt application with WorkManager integration, session-driven root navigation (auth vs main), Material 3 bottom navigation, deep links, backup exclusion rules, adaptive launcher icon Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
TypeScript/Express API (strict mode, typechecked): token verification with tenant custom claims, deny-by-default RBAC permission catalog, RFC 7807 problem+json errors, Idempotency-Key replay guard, and audit logging. Attendance punches are validated server-side (geofence with accuracy credit, kiosk HMAC TOTP tokens, speed-of-travel plausibility) and recorded append-only with AttendanceDay recomputation. Leave requests reserve balances transactionally with approval routing. Sync protocol: batched outbox push with per-op results and per-type delta-cursor pull. Firestore rules deny all direct client access; composite indexes cover every query. README documents setup, provisioning, and the repo layout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
…lar Hijri WorkTrack is now built for Afghanistan in Dari and Pashto: - Dari (fa-AF) is the DEFAULT locale: base resources are Dari, so any unmatched device language falls back to Dari; full Pashto (values-ps) and English (values-en) translations across every module with module-prefixed resource names - Every hardcoded UI string extracted to resources; domain/server errors now travel as typed AppError and are localized at render time by stable business code (geofence, leave balance, kiosk token, ...) - Solar Hijri calendar as the business calendar: tested Gregorian <-> Shamsi converter in core:common, Afghan month names (حمل...حوت / وری...کب), attendance history paged by Shamsi month, payroll periods interpreted as Shamsi months, dates/times rendered with Eastern Arabic digits - In-app language picker (دری / پښتو / English) via AppCompatDelegate per-app locales + android:localeConfig for Android 13+, RTL-first UI - ViewModels refactored from string messages to typed effects so no English text leaks from domain to UI; field errors map by key - docs/10-localization-afghanistan.md: language policy, calendar strategy, Friday weekend, AFN/Asia-Kabul defaults, known gaps; master spec and README updated (README now opens in Dari) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
Sync failed with NoToolchainAvailableException: "No locally installed toolchains match and toolchain download repositories have not been configured." The build pinned a strict Java 17 toolchain, but machines without a standalone JDK 17 (only the IDE's bundled JBR) had no way to satisfy or download it. - Drop strict Java toolchains in favor of source/target compatibility + Kotlin jvmTarget 17, so any JDK 17+ (including Android Studio's bundled runtime) builds the JVM modules and build-logic without a separate JDK - Add the Foojay toolchain resolver to root settings as a safety net so Gradle can auto-provision a JDK if a toolchain is ever reintroduced - build-logic build script uses .set() (embedded kotlin-dsl compiler has no property-assignment operator) The Android convention already used compatibility flags, so app/feature/ core Android modules were unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
intToLocalTime passed the Int? column value straight to LocalTime.ofSecondOfDay(Long), which Kotlin won't widen implicitly, so :core:database:compileDebugKotlin failed with "Inapplicable candidate(s): static fun ofSecondOfDay(p0: Long): LocalTime". Convert with it.toLong(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
AuthRepositoryImpl.signOut() called database.clearAllTables(), but clearAllTables() is declared on RoomDatabase — the supertype of WorkTrackDatabase — and core:database exposes Room only as an implementation dependency, so androidx.room.RoomDatabase was not on core:data's compile classpath. This produced four cascading errors in :core:data:compileDebugKotlin (cannot access RoomDatabase supertype, unresolved clearAllTables, and two withContext type-inference failures). Add a WorkTrackDatabase.clearAllTenantData() extension in :core:database (where RoomDatabase is visible) and call it from the repository. Room stays encapsulated in its own module; core:data never references a Room supertype. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
…path The previous fix wasn't enough: AuthRepositoryImpl injected WorkTrackDatabase as a constructor parameter, and merely referencing that type forces the compiler to load its RoomDatabase supertype — which isn't on core:data's classpath (core:database exposes Room only as implementation). So ":core:data:compileDebugKotlin" still failed with "Cannot access androidx.room.RoomDatabase which is a supertype of WorkTrackDatabase". Replace the WorkTrackDatabase injection with a new @singleton DatabaseCleaner (in :core:database, where Room is visible) that owns the clearAllTables() call and its background dispatch. core:data now injects DatabaseCleaner — whose only supertype is Any — and references no Room type at all. Removes the now-unused DispatcherProvider and withContext from the repository and the extension-function stopgap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
…json The build succeeds and installs, but the app crashed on startup with "Default FirebaseApp is not initialized" when no google-services.json is present: provideFirebaseAuth() calls FirebaseAuth.getInstance() eagerly while Hilt builds the graph in Application.onCreate. Inject dagger.Lazy<FirebaseAuth> in AuthRepositoryImpl and FirebaseAuthTokenProvider so getInstance() is deferred until an actual auth operation (sign-in / token fetch). The app now launches to the localized login screen even without Firebase configured; sign-in fails gracefully as an AppError instead of a fatal crash. Also expand the README Firebase setup steps, including the debug-build package-name gotcha (app.worktrack.debug) needed when registering the Android app and downloading google-services.json. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
Web admin console for managers/HR/branch leads — React 18 + TypeScript + Vite, Dari default with Pashto/English, RTL-first, Solar Hijri calendar throughout. Consumes the same /v1 REST API as the Android app. Portal (web/): - Firebase email/password login gated to manager roles (employees and kiosks rejected); GET /me resolves roles + tenant - Dashboard: today's KPIs (active/present/absent/on-leave/late/half-day/ pending-leave/attendance-rate) + 7-day Solar Hijri attendance trend - Employees: branch-scoped directory, search, add-employee form - Attendance monitoring: per-day live board with status, first-in, worked hours, lateness; Shamsi date picker - Leave approvals: pending queue with approve/reject (note required) - Foundation: typed API client (bearer token refresh + RFC 7807), TanStack Query hooks, AuthProvider with client-side RBAC gating, i18n provider (Dari/Pashto/English + Eastern digits), Solar Hijri converter ported from the Android core, CSS design system (teal, logical properties for RTL), sidebar layout. tsc + vite build pass. Backend (needed by the portal): - GET /employees (branch-scoped, paginated), GET /employees/:id, POST/PUT /employees (employees:read / employees:write) - GET /analytics/kpis, GET /analytics/attendance-trend - GET /attendance/overview (manager live board) - Composite employee indexes; wired into app.ts; typecheck passes Firebase Hosting configured (serves web/dist, rewrites /v1/** to the api function). README + web/README document setup and deploy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
…unset With an empty .env.local (freshly copied from .env.example), getAuth() was called with an empty apiKey, which throws at module load and leaves a blank white page with no hint why. Guard Firebase init behind a firebaseConfigured flag; when the web config is missing, render a bilingual (Dari/English) SetupNeeded screen listing the exact .env.local keys to fill in, and skip mounting the auth-dependent tree entirely. Also harden the API client against an undefined VITE_API_BASE_URL. tsc + vite build pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
Run the whole platform locally against the Firebase Emulator Suite with a sample Afghan tenant — no real Firebase project or billing needed. - backend/functions/seed.js + `npm run seed`: seeds the Firestore + Auth emulators with "شرکت ساختمانی کابل" (Kabul), 7 employees, 7 days of varied attendance (present/late/absent/half-day, Friday week-off), 3 pending leave requests routed to the admin, leave types/balances, announcements, AFN salary components, and 3 login users with custom claims (admin@ COMPANY_ADMIN, hr@ HR_ADMIN, ahmad@ EMPLOYEE; pw Passw0rd!) - web: connect to the Auth emulator when VITE_USE_EMULATORS=true; .env.emulator template so `cp .env.emulator .env.local` just works - docs/11-local-demo-setup.md: step-by-step Dari/English guide - gitignore web/.env.local and functions/.secret.local web build + seed syntax check pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
The setup guide started the emulator with a bare `firebase emulators:start`, which does NOT compile the TypeScript functions — so lib/index.js was missing, the `api` function never loaded, and the web portal login failed at GET /me with a generic error. - functions serve script now builds first and passes --config ../firebase.json --project demo-worktrack so it works when run from the functions dir - docs/11 terminal 1 uses `npm run serve` (build + start) instead of a bare emulators:start Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
Running the demo previously meant juggling three or four terminals in the right order, which is confusing and error-prone. Add run-demo.sh: a single command that builds the backend, then uses `firebase emulators:exec` to start the emulators, seed the sample tenant, and launch the web portal in one lifecycle — Ctrl+C stops everything. Checks for firebase CLI and Java up front, and auto-creates .secret.local and web/.env.local on first run. docs/11 now leads with `bash run-demo.sh`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
…zero The web Shamsi converter divided with Math.floor, but the jalaali algorithm needs integer division that truncates toward zero (as Kotlin Int `/` does in the Android port). For negative operands — e.g. div(gm-8, 6) when the Gregorian month is before August — Math.floor(-1/6) = -1 while the algorithm needs 0, so every date came out wrong: the dashboard showed "undefined ۱۴۰۴ ۲۹" (month index out of range → undefined month name) and the wrong year. Introduce div() = Math.trunc(a/b) and use it for all integer divisions. Verified: 2026-07-18 -> 27 Saratan 1405, 2026-03-21 -> 1 Hamal 1405 (Nawruz). The Android/Kotlin version was already correct. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
Lets the Android app sign in against the seeded demo tenant (ahmad@worktrack.af) with no real Firebase project or google-services.json. - EmulatorConfig: when BuildConfig.USE_EMULATORS, initialize the default FirebaseApp with demo FirebaseOptions (no google-services.json needed) and route FirebaseAuth to the Auth emulator at 10.0.2.2:9099 - WorkTrackApplication.onCreate applies it before any auth usage - debug build: API_BASE_URL -> the demo-worktrack Functions emulator, and USE_EMULATORS=true (release stays false / production) - debug manifest overlay + network_security_config permit cleartext HTTP to 10.0.2.2 (emulators) so Android's default HTTPS-only policy doesn't block it - docs/11: real step-by-step for running the app against the emulator, including setting the AVD location to Kabul for GPS punch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
Completes the payroll feature end-to-end so the previously-empty payslips screen (Android) and a new portal page show real data. Backend: - lib/shamsi.ts: Solar Hijri month -> Gregorian date range (trunc-to-zero) - services/payroll.ts: per-employee payslip calc — BASIC (EmployeeSalary) + EARNING components as gross; DEDUCTION components + loss-of-pay for unpaid attendance days as deductions; net = gross - deductions; day counts from attendanceDays over the Shamsi month - routes/payroll.ts: GET /payroll/runs, POST /payroll/runs (idempotent, payroll:run), GET /payroll/runs/:id/payslips (payroll:read); wired into app.ts - seed: per-employee salaries + a pre-generated finalized run for the current Shamsi month so payslips are visible immediately (Android + portal) Web portal: - Payroll page: pick a Shamsi month and run payroll, list runs with totals, drill into a run's payslips per employee; nav item + RBAC gating (payroll:*) - trilingual strings; AFN amounts; Shamsi periods Backend typecheck + web build pass; seed syntax checked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
Turns WorkTrack into a real multi-tenant SaaS: each company registers itself and gets its own isolated workspace. Backend: - services/signup.ts + routes/public.ts: public POST /v1/public/signup (mounted before the auth middleware) provisions a company, a head-office branch, the founding COMPANY_ADMIN (employee record + Firebase Auth user with tenant claims), and default leave types/balances. Rejects duplicate emails. Hardening notes (email verification, rate limiting) in docs/12. Web (Company Console): - LoginPage now toggles between Sign in and "Register your company"; signupCompany() calls the public endpoint then signs the admin straight in. Trilingual strings (Dari/Pashto/English). Docs: - docs/12-production-deployment.md: clear Dari/English step-by-step to deploy to a real Firebase project (functions, rules, indexes, hosting, Android release) and onboard the first company via signup. - README states the two products explicitly (Company Console web / Employee App Android) and links the demo + production guides. Backend typecheck + web build pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
Closes the gap where a manager could add an employee record but the employee had no way to sign into the app. Backend: - services/invite.ts: createEmployeeLogin (Firebase Auth user with uid = employeeId + tenant/RBAC claims) and resetEmployeePassword; readable temp-password generator. Assignable roles exclude COMPANY_ADMIN. - POST /employees now provisions the login (login created before the doc so a duplicate email doesn't orphan a record) and returns a one-time tempPassword; role/createLogin/initialPassword added to the schema. Doc is built explicitly so login fields never leak into the employee document. - POST /employees/:id/reset-password issues a fresh temp password. Web (Company Console): - Add-employee form gains a role dropdown, a "create mobile login" toggle, and an optional password; on success a credentials dialog shows the email + temp password (with copy) for the manager to share. Trilingual strings. Backend typecheck + web build pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEptpVZPfQYxHkX1cF7Yd2
…rove) Employees can file a correction for a past day's clock-in/out; managers with attendance:approve review and decide in the web portal, and an approval writes the corrected times onto the day's attendance projection. Backend - services/regularization.ts: create (idempotent on ULID, routes to the employee's manager) and decide (authz + on approve applies corrected times, clears lateness, marks the day regularized); RFC7807 errors + audit - routes/attendance.ts: POST /regularizations, GET /regularizations (mine|approvals), POST /regularizations/:id/decide - sync.ts: regularizations in the pull registry (employeeOrApprover) and the push applyOp so the Android outbox can create them offline-first - firestore composite indexes; two pending demo requests in the seed Web portal - Attendance page shows a "pending corrections" card gated on attendance:approve, with approve/reject (reject requires a note) - api types + hooks (usePendingRegularizations / useDecideRegularization), attendance:approve added to the client RBAC map, fa/ps/en strings Android (employee app) - RegularizationCommand model + RegularizationCreateDto wire type - AttendanceRepository.requestRegularization (outbox + immediate sync) and RequestRegularizationUseCase (local validation; server authoritative) - Attendance history: per-day "request correction" dialog with 24h time pickers for in/out + reason; fa/ps/en strings
…theme Web manager portal — a refined, RTL-first design system: - New token system (brand/surface/text/semantic scales) with a full dark theme via prefers-color-scheme; Vazirmatn web font loaded - Split-hero login/signup: teal gradient hero with value props beside a clean auth form (collapses to single column on mobile) - App shell: sticky glassy app bar, sidebar with brand mark, inline SVG nav icons, active-item accent rail, and a user card - Elevated KPI cards with colored accent bars and tabular figures, softer tables (uppercase sticky headers, row hover), dot-prefixed status chips, gradient trend bars, button/input focus rings and micro-motion - i18n keys for the new hero/menu strings across fa/ps/en Android employee app — premium theme tokens (safe, value-only): - Rounded Shapes scale wired into the M3 theme (cards 16dp, etc.) - Layered light/dark surface-container roles + surfaceTint/outlineVariant for a clearer elevation hierarchy Verified: web `tsc -b && vite build` green; login and dashboard rendered via headless Chromium look correct in RTL. Android theme is value/stable-API only (no compile in sandbox) — please report any build errors.
- Theme system: light/dark/system with a persisted preference and a no-FOUC inline resolver in index.html; explicit data-theme overrides win over prefers-color-scheme, with the OS media query kept as a no-JS fallback - ThemeProvider + a sun/moon ThemeToggle button in the app bar and on the login screen; smooth token transitions when switching - Dashboard KPI cards get tinted metric icons; attendance trend gains baseline gridlines - New dot-less .chip-count badge so numeric counts don't read as a digit after the status dot; used by the pending-corrections badge - i18n keys for the theme toggle across fa/ps/en Verified: tsc -b && vite build green; light and dark rendered via headless Chromium (login + dashboard) both look correct in RTL.
… admin
Shift scheduling (24-hour & night shifts)
- services/shifts.ts: shift CRUD + roster assignment; night/24h detected from
start/end (end<=start wraps past midnight; start==end is a 24h shift);
idempotent per employee/day roster keyed `${employeeId}_${date}`, batched
- routes/shifts.ts: GET/POST/PUT /shifts, POST /shifts/roster/assign,
GET /shifts/roster?date=; rosters:read/write, added to HR_ADMIN & TEAM_LEAD
- The Android app already consumes shifts via sync (company scope), so
backend-created shifts flow to employees with no client change
Editable features + policies (company settings)
- services/settings.ts: per-company feature toggles (shifts/leave/payroll/
regularization/announcements/geofencing/qrKiosk/faceRecognition) + work
policies (daily minutes, weekend days, late grace, overtime) + profile,
stored on the company doc, merged over defaults so the shape can grow
- routes/settings.ts: GET (any member, for feature flags) / PUT (admin only)
- /me now returns `features` + `currency`; signup + seed provision defaults
Dedicated admin (web)
- SettingsPage: feature switches, work policies, profile — gated to
COMPANY_ADMIN via settings:write; toggling a module hides it from the nav
- ShiftsPage: manage shift definitions (day/night/24h badges) + daily roster
with a shift-assignment dialog; gated to rosters:read/write
- Layout gains Shifts + Settings nav (feature-flag aware); Switch component;
toggle/chip-picker styles; fa/ps/en strings
Verified: backend tsc + seed check green; web tsc -b && vite build green;
Settings and Shifts pages rendered via headless Chromium (RTL, light + dark).
Kept the flat, high-contrast design system (per the design decision) but borrowed the reference designs' signature smooth line chart in place of the bars: Catmull-Rom area+line with gridlines, per-point dots, and a value bubble on today. Caps pinned to LTR so they align with the SVG points regardless of the page's RTL direction. Works in light and dark.
Introduces --accent (orange) tokens for light and dark, used sparingly as the energetic second color per the reference designs: the attendance-rate KPI (headline metric), the trend chart's "today" point + value bubble + marker, and a .btn-accent utility. Teal stays the primary brand.
Feature flags (completes the settings feature on the phone) - MeDto gains a features object; CompanyFeatures added to the domain model and threaded through /me → UserSession → persisted SessionStore (defaults on, so older payloads/servers hide nothing) - MainScaffold hides a bottom-nav module the company switched off (Leave gated on features.leave; mirrors the web portal). Payslips/dashboard-tile gating is a follow-up. Orange secondary accent (matches the web "second color") - Orange token scale added; wired as the M3 tertiary role in light + dark (tertiary wasn't used elsewhere, so attendance chips keep their amber) - Selected bottom-nav tab now uses the orange tertiary container Note: the Android toolchain can't run in this sandbox — changes are value/stable-API only and were reviewed by hand; please report any build errors from Android Studio.
The token verification side already existed (employees scan a kiosk QR and punch); what was missing was the issuer/display. Adds it: Backend - GET /kiosk/token?kioskId= mints the current rotating token via the existing HMAC signer (secret stays server-side); requires kiosk:issue, now granted to HR_ADMIN and BRANCH_MANAGER (COMPANY_ADMIN via "*") Web - KioskPage: full-screen, distraction-free display (outside the portal chrome) with the company name, a live clock, a big scannable QR, and instructions; polls a fresh token every 20s (ahead of the 30s slot) - QR rendered client-side via the qrcode lib; "Kiosk" nav entry gated on kiosk:issue AND the qrKiosk feature flag; fa/ps/en strings The Android app already scans this token and punches (method QR → kioskToken), and the demo already provisions KIOSK_HMAC_SECRET — so the full flow works end-to-end with no client change. Verified: backend tsc green; web tsc -b && vite build green; kiosk screen rendered with a real QR in light and dark.
A company can now provision a KIOSK-role login per tablet so it stays on the
check-in screen unattended, with no manager account involved.
Backend
- services/kiosk-account.ts: create/list/reset dedicated KIOSK logins
(Firebase Auth user + {cid, eid, r:["KIOSK"], b} claims + a devices doc);
synthetic unique email, one-time temp password. No employee record — its
only power is kiosk:issue.
- routes/kiosk.ts: POST/GET /kiosk/accounts, POST /kiosk/accounts/:id/reset
(employees:read/write); /kiosk/token now also returns companyName.
Web
- AuthProvider: a KIOSK-role sign-in is detected from token claims and
routed straight to the full-screen kiosk display — it never calls /me
(kiosk accounts have no employee doc). New "kiosk" auth status.
- KioskPage: works for a locked device (exit → sign out) and a manager
preview (exit → dashboard); shows the company name from the token.
- Settings: "Kiosk devices" card to create a login (shows the one-time
email + password to type into the tablet), list devices, and reset a
password. Shown when the qrKiosk feature is on. fa/ps/en strings.
Note: run `npm install` in web/ first — qrcode is a new dependency.
Verified: backend tsc green; web tsc -b && vite build green; Settings kiosk
card and the kiosk display rendered via headless Chromium.
…open
Everything WorkTrack computes stopped being usable at the edge of the
screen. There was no print stylesheet anywhere in the portal, no export
of any kind, and payslip.pdfUrl has been null since it was written. Most
workers here have no bank account: pay is counted out in cash against a
signature or a thumbprint, and without that sheet a company cannot
answer a tax inspector or a main contractor asking how it knows the
money arrived.
The design is driven by the paper. The signature column is wide and
completely empty — anything printed in it is something a person has to
sign around. Rows are keyed by employee code, because two people called
احمد in one company is the ordinary case and a signed line has to say
which one signed it. Totals sit at the foot, where the person carrying
the cash checks them before starting. No colour and no zebra striping:
these print on whatever is in the office, and grey swallows ink and
hides a pencil signature.
The CSV is deliberately Latin-digit and comma-separated with a BOM. It
is read by Excel, not by a person: eastern digits arrive as text and
every column stops adding up, which is the one thing the accountant
opened it for, and without the BOM every Dari name is mojibake on
Windows.
One bug here was invisible to all thirteen tests and would have shipped.
The print rule hides `body > *:not(.sheet-backdrop)`, and React rendered
the modal inside #root — so printing hid #root, took the sheet with it,
and produced a BLANK PAGE. jsdom does not print and it looked correct on
screen; only opening a browser and asking which elements the rule
matched showed it. It renders through a portal into <body> now, and the
one assertion that catches it is there.
Also adds the audit the portal never had: a test that scans every
literal t("…") in the source and fails if a key is missing from any of
the three dictionaries. t() falls back to returning the key, so a
missing string is not an error — it is the literal text `common_close`
sitting on a button, shipped, with everything green. Two went out this
week and a person spotted both, which is not a system. iOS has had
check-strings.py doing this since it was built.
388 backend and 182 portal tests pass. Verified on the deployed demo,
including that the print rule now matches #root and not the sheet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Monthly salary was the only model this system had, and it does not fit the two kinds of business the product is most often sold to. A construction firm hires by the day; a tailoring workshop pays by the garment. Both are core market and neither was served. The whole safety property is one flag. A monthly salary is paid whole and then reduced for days not worked. A daily wage already contains that: somebody absent ten days is paid for the twenty they came and owes nothing for the ten. Charging loss-of-pay on top takes those days TWICE — once by not paying them, once by deducting them — so earnedBasic returns chargeUnpaidAbsence: false for daily and piece work, and payroll only writes the LOP line when it is true. Writing the test for that took two attempts, and the first one was worthless. It used a worker absent the whole month, and passed with the suppression removed — with nothing earned there is nothing to deduct, because loss of pay is already capped at gross. Only PARTIAL attendance shows it. With the fixed test, removing the suppression deducts 3,580.77 from a worker who earned 13,300: 27% of their wage, taken twice. Piece counts are stored one document per entry rather than as a monthly total, because a total nobody can break down is a total nobody can dispute, and disputes about piece counts are what a workshop's book exists to settle. Recording one is gated on payroll:run, not payroll:read — for anybody on that model the count IS the wage, so it is the same kind of act as setting a salary. A salary with no model on it is paid monthly. Every record written before today is in that state, and the alternative to that default is a run that pays a month's salary for each day worked. The index went out first this time, and I waited for READY rather than assuming: it sat in CREATING for about four minutes, which is exactly the window that 500s a customer if the code ships first. Verified on the live demo, not only in the emulator: an employee moved to a 1,500 daily wage produced BASIC 22,500 for 15 days worked, lopDays 1 recorded, and NO deduction line — where the same absence on a monthly salary had produced one. Restored to monthly afterwards so the seeded demo tenant is unchanged. 412 backend and 182 portal tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing did, on any platform. A worker found out their leave was approved by opening the app and going to look, which in practice meant finding out by asking their manager — which is the conversation this product exists to replace. Leave decisions, attendance corrections and finished payslips now write a notification, and the portal grows a bell in the header rather than a page: news you have to navigate to is news you do not get. The load-bearing property is that a notification NEVER breaks what caused it. Approving leave is the act that matters; telling the employee is not. Every function here swallows its own errors and logs them, and no caller is given anything to handle — the failure mode chosen on purpose is "the approval worked and nobody was told", because the alternative is "the approval was refused because we could not tell them". There is a test that makes Firestore throw and asserts notify() still resolves. Reads are scoped to the caller's own employee id, with no permission gate at all: a notification is addressed to a person, there is nothing here a manager should see more of, and marking somebody else's read by guessing an id should not be possible. Verified against the live demo — the admin gets a 404 on the worker's notification. Two things were found only by opening it in a browser and looking: The body read "دورهٔ 1405/06" in Latin digits among Dari prose where every other number is ۱۴۰۵. It reads as a rendering fault. Digits are localised at write time now, with the honest limit written down: these strings are Dari only, and serving them per reader means storing structured data instead of prose — worth doing when notifications reach the apps, not before. Worse, re-running payroll sent everybody another "your payslip is ready". Payroll is deliberately re-runnable and idempotent everywhere else — payslip ids and the journal entry are derived from the run — and this was not, so four runs of one month meant four identical messages each. A dedupe key derived from the run makes the second run replace the first. Confirmed live: seven consecutive runs, one notification. Indexes went out first and I waited for READY: both sat in CREATING for about five minutes. 424 backend and 193 portal tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tazkira, contract, work permit, health certificate. A firm working for an NGO or a ministry has to hold these, and the expensive case is not a missing one — it is the contract that quietly ran out four months ago, which means somebody has been working without one and the company cannot answer for it. Deliberately a REGISTER and not a filing cabinet: what the document is, its number, and when it stops being valid. The scan itself needs Cloud Storage and an access decision that deserves its own thought, and the expiry warning is worth having long before the photograph is. The nightly sweep is what makes it a control rather than another place nobody looks. It is addressed to whoever can act — the roles holding employees:write — because telling an employee their own permit expires is well meant and useless: they cannot renew the company's copy. It is keyed to the day, so a retry or a redeploy does not send the warning twice, and it warns again tomorrow because the paper is still expired. One company's bad data cannot stop the sweep for everybody else. "Does not expire" is a distinct state from "valid", in the rules, the chips and the tests. A tazkira shown as valid among contracts that really do expire teaches people to stop reading the column, which costs more than showing nothing. A contract is also valid ON the day it expires — calling it expired sends somebody home a day early. Two smaller things found on the way. The TenantCollection union already had a "documents" entry that nothing used, so this fills it rather than adding a second collection meaning the same thing; and my own earlier commit had added a duplicate "notifications" to that union, now removed. Verified live: a contract expiring in ten days reports EXPIRING with daysLeft 10, and a tazkira with no expiry is absent from the list rather than reported as valid. 440 backend and 203 portal tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Apple requires a Support URL and I had pointed it at the marketing site, which a reviewer can reasonably object to: it is a product page, not a place to get help. The page's real job is routing. The person holding the app did not buy it and cannot fix most of what goes wrong with it — their employer made the account, drew the work site and runs the payroll. So it says that first, in a callout, and then answers the handful of questions that actually get asked: cannot sign in (there is no self-registration), forgot password, "outside the work site", attendance that did not count, a wrong payslip, no internet. Trilingual and styled like /privacy/, so there is one voice and one file to copy next time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The doc had left the territory question open with "check the list yourself, it's a minute of work". Setting up Pricing and Availability answered it: Afghanistan is in the list of 175, first row after the base country. So an Afghan customer can install from the App Store directly, and the old forum thread that said otherwise is stale. Also records the age-rating answers and why, because the questionnaire comes back at every update and the answers have to stay the same unless the app actually changed. The one worth reading twice is user-generated content: the app has free-text fields, but Apple's definition turns on "broad distribution", and a leave reason read by one manager is not that. Answering yes would have obliged us to build reporting and moderation for a leave-reason box. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TensorFlowLiteSwift's podspec targets iOS 12, which current Xcode refuses outright: "the range of supported deployment target versions is 15.0 to 27.0". It only ever built here because DerivedData had the result cached, so a clean checkout or CI failed on the first build with an error that reads as if it came from our code. Raising every pod to 16.0 — already the floor the app itself is built against — fixes it and changes nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A manager switches face check-in on in the portal, tells the worker to
look, and nothing happens. Features ride on `me`, and `me` was only ever
read in `start()` — at launch. Bringing the app back from the switcher
changed nothing and explained nothing, so the feature stayed invisible
until the app was force-quit. Nobody guesses that; I didn't either until
I went looking for why the button was missing.
The error handling is deliberately lopsided, and that is the part worth
reviewing. This runs on every foreground, on a lot of phones:
- offline, a 5xx, a body we could not read -> keep the identity we
have. A bad minute on the server must not empty a site full of
phones onto the login screen, where nobody could sign back in
either, because signing in needs the same server.
- 401 -> end the session. That one is wanted: a revoked token is what
disabling an employee produces, so somebody who has left the company
stops being in the app at the next foreground instead of lingering
until they happen to tap something.
That decision is split out as a pure `outcome(for:)` so it can be tested
exhaustively, because getting it backwards does not crash or throw — it
just quietly locks everybody out one bad afternoon.
Ending a revoked session is narrower than signing out: it leaves the
work cache alone. A token is also revoked when an admin merely RESETS a
password, and throwing away a punch the phone has not sent yet, over
what is to the worker a password change, would cost him the morning.
Verified on the simulator against the demo tenant, app backgrounded and
foregrounded rather than killed: switching the flag off made the button
disappear, switching it back on brought it back. 105 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Google Play has required API 36 of new apps and updates since 31 August 2026. We target 35, so a Play listing is refused outright today — which matters now that we want the app on Play rather than only as a sideload from the download page. targetSdk alone was not enough. compileSdk must be at least targetSdk, and AGP 8.5.2 says plainly that it "was tested up to compileSdk = 34": it builds 36, but shipping a release compiled against a combination the plugin has never been tested on is not a thing to do to the artifact that is actually in customers' hands. AGP 8.13 is the smallest move that fixes it — the last of the 8.x line, supporting compileSdk up to 36.1 and needing Gradle 8.13 and JDK 17, which we already use. The alternative was AGP 9.4, a major version that also demands Gradle 9.6, and none of that is warranted to gain one API level. Targeting 36 opts the app into Android 16's behaviour changes. The one that would have hurt is enforced edge-to-edge: an app that does not consume insets ends up drawing under the status and navigation bars. MainActivity already calls enableEdgeToEdge() and MainScaffold pads by the Scaffold's insets, so it was already running that way and nothing changes. Verified: debug build clean, the compileSdk warning is gone, lint has no errors, and all 50 unit tests across the modules pass. Still unverified, and it needs a device: this changes runtime behaviour for real, and no release build has been made or signed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
We sell Android as a sideload today, so none of the Play paperwork exists. Writing it now means tomorrow is filling in forms rather than composing copy at the console. Two things in here are not paperwork and are worth reading before starting: Play App Signing is a one-way door — accept it and Google holds the real key, and worktrack-release.jks becomes merely an upload key. Worse, and easy to miss: a Play build and our current sideloaded APK are signed differently, so nobody who installed from the download page can update in place. They must uninstall and reinstall, losing any check-in the phone has not yet sent. Existing customers have to be warned before the move, not after. The Data Safety answers are written to match the App Privacy labels we published to Apple, deliberately. Two different accounts of what one product collects is how a listing gets pulled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The download page already serves worktrack-1.1.0-*.apk, built against targetSdk 35 with AGP 8.5.2. The bundle we are about to upload to Play is targetSdk 36 on AGP 8.13 — a different binary in the ways that actually produce bugs. Shipping both as "1.1.0" means a crash report naming that version could be either one, and neither of us would know which. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Generating a signed bundle failed in buildReleasePreBundle with
"Multiple shrunk-resources files found ... Please disable building
multiple APKs when building an Android app bundle". The message names
the symptom. The cause is that we configure ABI splits to produce the
arm32/arm64/universal APKs the download page serves, and a bundle splits
by ABI itself on Google's servers, so the two cannot both be on.
Both outputs are needed — Play wants the bundle, and customers install by
sideload from the download page, where the small per-ABI files matter on
Afghan connections. So the splits now switch off only when the build is
a bundle.
The first attempt matched task names with startsWith("bundle"), which is
wrong in a way that looks right: the task that actually trips over this
is buildReleasePreBundle, which begins with "build". It silently did
nothing and the build failed identically. Matching anywhere in the name
fixes it.
Verified both directions rather than the one I cared about:
assembleRelease configures with splits ON, bundleRelease with splits
OFF, and buildReleasePreBundle — the task that was failing — now passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The listing cannot be submitted without an icon, a feature graphic and at least two phone screenshots, and none of the three existed. The icon is rendered from the app's own ic_launcher_foreground.xml on the same #006874 launcher background, so the store and the phone show one mark rather than two drawings of it. The feature graphic is plain brand navy with the same clock — correct, not designed, and the first thing to replace if this ever gets a designer. The screenshots are the real app on a Pixel 8 against the seeded demo tenant, not mockups. One of them shows the geofence refusing a check-in 4,360 m from the site, in red. That is the product's whole argument, but it is a warning on a store page, so the README says how to retake it from inside the fence if that reads badly. They have to be dragged in by hand: Play creates its file input only on click, which opens the native picker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Add ios/, desktop/, delivery/, scripts/ to the repo layout table - List the iOS app alongside Android in the products table - Add docs 17 (business types) and 18 (Google Play) to the index - Update Android SDK 35 → 36 and Gradle 8.9 → 8.13 in build prereqs - Add a Distribution section (Play closed testing, App Store in review) - Trim the roadmap blurb to a pointer - Rename 16-google-play.md → 17-google-play.md (16 was taken) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add ios/, desktop/, delivery/, scripts/ to the repo layout table - List the iOS app alongside Android in the products table - Add docs 17 (business types) and 18 (Google Play) to the index - Update Android SDK 35 → 36 and Gradle 8.9 → 8.13 in build prereqs - Add a Distribution section (Play closed testing, App Store in review) - Trim the roadmap blurb to a pointer - Rename 16-google-play.md → 17-google-play.md (16 was taken) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Static page at /landing/ with Dari (default), Pashto, and English. Hero with gradient, 6 feature cards with SVG icons, screenshot carousel from real app screenshots, employer portal section, download CTA, and footer. RTL-first, responsive, same styling family as /support/ and /privacy/. Store badge links are placeholder (#) until apps are approved. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Static page at /landing/ with Dari (default), Pashto, and English. Hero with gradient, 6 feature cards with SVG icons, screenshot carousel from real app screenshots, employer portal section, download CTA, and footer. RTL-first, responsive, same styling family as /support/ and /privacy/. Store badge links are placeholder (#) until apps are approved. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The first version read like a SaaS template — feature grid, bullet points, marketing jargon. This rewrite uses a narrative structure (problem → solution), conversational Dari/Pashto/English copy, and puts the app screenshots front and center instead of burying them. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The first version read like a SaaS template — feature grid, bullet points, marketing jargon. This rewrite uses a narrative structure (problem → solution), conversational Dari/Pashto/English copy, and puts the app screenshots front and center instead of burying them. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The CSS `display: inline-block` on `.btn-login` was overriding the
HTML `hidden` attribute, so all three login buttons showed at once.
Fixed with `[hidden] { display: none !important }` and corrected
the JS selector from `[class^='login-']` (which never matched
because the class attribute starts with `btn-login`) to `.btn-login`.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The CSS `display: inline-block` on `.btn-login` was overriding the
HTML `hidden` attribute, so all three login buttons showed at once.
Fixed with `[hidden] { display: none !important }` and corrected
the JS selector from `[class^='login-']` (which never matched
because the class attribute starts with `btn-login`) to `.btn-login`.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace placeholder # hrefs with actual Google Play (app.worktrack) and App Store (id6810004398) URLs across all three languages and CTA sections. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…inks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add real store links to landing page
Switching language rebuilt the root view, which re-ran session restore and could end a live session; it also reset the open tab. Built-in leave types and payroll lines arrived with Dari names from the server and showed as Dari on English and Pashto screens; they are now translated by code, while names a company typed stay as typed. Build 3 for App Review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
iOS: keep session and English labels on language change (build 3)
The server stores the seeded leave types and the payroll-written payslip lines with Dari names, so English and Pashto screens showed Dari. They are now translated by code, the same way the iOS app does it; names a company typed stay as typed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Android: translate built-in leave and payslip names
Several Pashto sentences borrowed Dari words or had grammar errors: کارمند and کار فرما are Dari (Pashto uses کارکوونکی and کارګمارونکی), شرکت is masculine, "د ویب پورټال هم لرئ" had a stray «د», and one line said the app had been translated "into Persian". The same corrections are already live on the WordPress copy of this page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Landing page: correct the Pashto copy
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
maincurrently holds a 56-byte README and nothing else, so the repository thewebsite links to looks empty to anyone who opens it. All 59 commits of the actual
platform sit on
claude/worktrack-hrms-platform-tlar35, 0 behind main.This merges that branch so
mainis the code.Checked before opening:
local.properties, nogoogle-services.jsonweb/.env.example(empty placeholders) andweb/.env.emulator(Firebase Emulator demo values, not a real project).gitignorealready excludeslocal.properties,app/google-services.json,backend/.firebasercandbackend/functions/.env*🤖 Generated with Claude Code