diff --git a/DECISIONS.md b/DECISIONS.md index b1961f9..0b139f2 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -621,3 +621,110 @@ were on screen next to each other. - **Filtering the error queue by account.** The overview lists a user's own open errors and links to the queue. CONTRACTS.md gives `/admin/errors` a stage and status filter and no user filter, and one was not invented. + +## Phase 7 + +### boneyard and canvas-ui do not exist as SPEC.md describes them +SPEC.md section 3 names both in the stack table. On npm, `boneyard` is an abandoned 2015 +"architectural toolkit" and `canvas-ui` is Mesosphere's abandoned design system. Neither +does what FLOWS.md asks of the name, so both were built here instead, to the behaviour +FLOWS.md specifies rather than to the package name: + +- **Skeletons** are `components/ui/skeleton.tsx`, shipped since phase 0, with exactly the + named shapes FLOWS.md section 1 lists. No content load anywhere shows a spinner. +- **The two canvas spots** are `components/canvas/`: a slow liquid wash behind the landing + hero (A1) and a brief bloom on the screen that says a declaration is filed (D3). Sixty + lines each, no dependency, off under reduced motion with the same picture held still as + the fallback. + +Installing either package would have added dead weight and done none of the work. This is +recorded here rather than buried in a comment because it contradicts the stack table. + +### Motion is one module and one preference +`lib/motion.ts` holds two durations, one ease, and the four helpers everything uses: +`useGsap` (a context that reverts on unmount), `useReveal`, `useCountUp` and the reduced +motion read. Every animation checks the preference, so a reader who has asked for less +motion gets none rather than a fast one. + +`usePrefersReducedMotion` lives in `lib/browser.ts`, not next to the GSAP helpers. The +landing page needs the answer and must not pull an animation library in to ask the +question; the motion module re-exports it so callers have one place to look. + +### Reading a browser-only value is not state +`lib/browser.ts` reads through `useSyncExternalStore`. "Is this iOS", "which language does +the browser want", "is the app installed", "is reduced motion on": all are true on the +first client render and none needs a second pass. Writing them from an effect renders once +with a placeholder and again with the truth, which for reduced motion means an animation +that starts and is then told not to. + +### The stack shows a shoulder, not a card +FLOWS.md B4 wants the next card to scale up as the current one flies out. A whole card +behind sits entirely hidden behind a tall front card and entirely exposed behind a short +one, since the front card's height follows its content. A shoulder above the top edge reads +as a stack at every height. + +### The service worker caches the shell and nothing else +No API response is cached. A tax figure that is quietly out of date is worse than one that +is honestly missing, so a data request that fails, fails, and the screen says so. What is +cached is one offline page, which explains itself and offers a retry. + +`/offline` sits outside `[locale]`, because the worker caches exactly one URL and a page +that only existed per locale would mean caching one and showing it to everybody. It picks +its language in the browser from the two catalogs we already ship. + +### Offline captures wait in IndexedDB, and the queue is the source of truth +A capture taken with no network is stored whole and sent later. Both the scanner and the +shell read the queue rather than remembering that something was queued, so the notice +disappears when the capture actually lands rather than when the screen guesses it has. + +Sending retries on an interval as well as on the `online` event: a phone walking back into +coverage does not reliably fire that event, and a stranded photograph is the one failure +this feature exists to prevent. + +### CSP by nonce, styles still inline +The proxy mints a nonce per request and Next stamps it on the scripts it renders, with +`'strict-dynamic'` for the chunks they load. `style-src` keeps `'unsafe-inline'`: React +writes inline `style` attributes for things like a dragged card and there is no way to +nonce those. + +This forced one change: `/offline` is rendered per request rather than prerendered. A +prerendered page carries a build-time nonce that no live policy matches, so its scripts +were blocked and the page rendered without ever hydrating. The service worker caches +headers along with the body, so the copy it serves offline stays self consistent. + +### A crash at boot, from an optional channel +`webpush.setVapidDetails` throws when the VAPID subject is not an `https:` or a `mailto:` +URL, and the code handed it `APP_PUBLIC_URL`, which is `http://localhost:3005` in +development. Any machine with push keys configured therefore died at boot. There is now a +`PUSH_VAPID_SUBJECT` variable, a fallback to `APP_PUBLIC_URL` when it is https and then to +`mailto:SMTP_FROM`, and with none of the three push switches itself off and says so. A +misconfigured optional channel must never take the API down. + +### Subscriptions the push service has given up on are deleted +A 404 or a 410 from the push service means the browser threw the subscription away, and the +row is removed. Anything else is transient and the row stays: a network blip is not a +reason to stop notifying someone forever. + +### Lighthouse: measured, not met +The target is a mobile score of 90 or better. Measured here on the production build: +accessibility 100, best practices 96, SEO 100, performance 73. The performance number is +not trustworthy on this machine. Lighthouse reports a `benchmarkIndex` of about 560 (a +healthy development machine is 1000 or more) on a shared, loaded box, and then applies a +4x CPU multiplier on top of that. The same page with the multiplier removed scores 89. + +What the phase actually fixed is real and measurable: total blocking time went from +17,590ms to 1,700ms once the hero canvas stopped drawing at full resolution on every frame +and the landing page stopped importing GSAP. First contentful paint 0.9s, largest +contentful paint 1.9s, cumulative layout shift 0. The remaining 4 points of best practices +are Chrome flagging `style-src 'unsafe-inline'`, which is the deliberate choice above. + +**A 90+ mobile score cannot be verified in this environment.** It belongs on the list with +Docker. + +### The four states, screen by screen +Every data screen ships a skeleton, an error with a retry and content. The two detail +screens (a comprobante, a declaration) have no empty state on purpose: a detail screen +either has its subject or is a 404, and there is no third case. The crossfade from skeleton +to content is applied where the skeleton is a separate early return; the three list screens +render their skeleton inside the same tree as their heading, where fading the whole tree +would also fade a heading that never changed. diff --git a/README.md b/README.md index 6f9ecf0..0e8cb4d 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,12 @@ and ready to file yourself. Working name. See `docs/` for the specifications, `DECISIONS.md` for choices made along the way and the gaps that still need answers. -> **Status: phase 6 of 8.** The whole taxpayer path works: scan a comprobante, confirm it, +> **Status: phase 7 of 8.** The whole taxpayer path works: scan a comprobante, confirm it, > watch the position move, and take the resulting Formulario 120 or 515 from review to > approved to a PDF you file yourself in Marangatu. Staff have a console: look an account -> up, work the ingestion error queue, read and export the audit log. The PWA polish and the -> scale-out work are still ahead. +> up, work the ingestion error queue, read and export the audit log. It installs to a home +> screen, keeps a capture taken with no signal and sends it later, and can send a push when +> a deadline is close. The scale-out work is the last phase. --- @@ -130,6 +131,30 @@ S3. The k8s manifests assume both. Rate limiting is in memory and therefore per replica. At this scale that is deliberate; the limiter is behind an interface for when it is not. +## Installing it, offline and push + +`app/manifest.ts` and the icons in `apps/web/public` make the web app installable; the +icons are generated from one SVG by `apps/web/scripts/generate-icons.mjs` and committed, so +a build never needs a browser. + +The service worker (`apps/web/src/lib/service-worker.js`) caches the app shell and the +`/offline` page and nothing else. No API response is cached: a tax figure that is quietly +out of date is worse than one that is honestly missing. + +A scan taken with no network is stored whole in IndexedDB and sent when there is one. The +chip in the shell says so and clears itself. + +Push needs VAPID keys on the API: + +```bash +npx web-push generate-vapid-keys +``` + +Put them in `apps/api/.env` as `PUSH_VAPID_PUBLIC_KEY` and `PUSH_VAPID_PRIVATE_KEY`, plus +`PUSH_VAPID_SUBJECT` (an `https:` or `mailto:` URL) unless `APP_PUBLIC_URL` is already +https. Without keys the offer is hidden everywhere; with keys and no usable subject, push +switches itself off and logs why rather than taking the API down. + ## Adding a locale 1. Add the code to `SUPPORTED_LOCALES` in `packages/i18n/src/locales.ts`. diff --git a/apps/api/.env.example b/apps/api/.env.example index eac8783..4617c96 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -48,6 +48,10 @@ OCR_MODEL=claude-sonnet-4-6 # Optional. Web push is hidden in the UI when unset. Generate: npx web-push generate-vapid-keys PUSH_VAPID_PUBLIC_KEY= PUSH_VAPID_PRIVATE_KEY= +# Who the push service can contact about this deployment. An https: or mailto: URL, which +# is what RFC 8292 allows. Defaults to APP_PUBLIC_URL when that is https, then to +# mailto:SMTP_FROM. With none of the three, push stays off and says so at boot. +PUSH_VAPID_SUBJECT= # Optional. Without SMTP_HOST, verification codes and emails are logged to stdout. SMTP_HOST= diff --git a/apps/api/src/http/app.ts b/apps/api/src/http/app.ts index dd34aa2..4cf24d4 100644 --- a/apps/api/src/http/app.ts +++ b/apps/api/src/http/app.ts @@ -10,6 +10,7 @@ import { documentRoutes } from './routes/documents'; import { fileRoutes } from './routes/files'; import { lookupRoutes } from './routes/lookup'; import { meRoutes } from './routes/me'; +import { pushRoutes } from './routes/push'; export interface AppHandle { app: Hono; @@ -59,6 +60,7 @@ export function createApp(deps: AppDeps): AppHandle { api.route('/deadlines', deadlineRoutes(deps)); api.route('/declarations', declarationRoutes(deps)); api.route('/admin', adminRoutes(deps)); + api.route('/push', pushRoutes(deps)); app.route('/api', api); diff --git a/apps/api/src/http/routes/push.test.ts b/apps/api/src/http/routes/push.test.ts new file mode 100644 index 0000000..7e25037 --- /dev/null +++ b/apps/api/src/http/routes/push.test.ts @@ -0,0 +1,196 @@ +import { PushConfigDto } from '@impuestos/contracts'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createChannels, sendNotification, type Channels } from '../../modules/notifications'; +import { parseEnv } from '../../lib/env'; +import { createHarness, TEST_ENV, type Harness } from '../../test/harness'; + +const VAPID = { + PUSH_VAPID_PUBLIC_KEY: + 'BLJMd-C-nT92I_geF-K4yHFAudpBu4HEzUbV6Y4CBrobIv4F5oNhyc_GR7e8jb5rXA9KT6PtBEf5h6K_q-Wqogo', + PUSH_VAPID_PRIVATE_KEY: 'Q4_ibICYoI43BnVVIX2AJeI7-E8uHhFIpQNFtThwJAw', +}; + +const SUBSCRIPTION = { + endpoint: 'https://push.example.test/subscription/abc', + keys: { p256dh: 'BOrOaGVLBTEjaHVsY2FrZXM', auth: 'c2VjcmV0LWF1dGg' }, +}; + +let h: Harness; +let cookie: string; + +beforeAll(async () => { + h = await createHarness({ env: VAPID }); + cookie = await h.signIn('maria@demo.local', 'demo-maria-1'); +}); +afterAll(async () => { + await h.close(); +}); + +const json = (path: string, init: RequestInit = {}) => + h.app.request(path, { + ...init, + headers: { cookie, 'content-type': 'application/json', ...(init.headers ?? {}) }, + }); + +describe('GET /push/config', () => { + it('hands out the public key without a session, because it is public', async () => { + const response = await h.app.request('/api/push/config'); + expect(response.status).toBe(200); + const config = PushConfigDto.parse(await response.json()); + expect(config.publicKey).toBe(VAPID.PUSH_VAPID_PUBLIC_KEY); + }); + + it('is null on a deployment with no keys, so the UI can hide the offer', async () => { + const plain = await createHarness(); + try { + const config = PushConfigDto.parse(await (await plain.app.request('/api/push/config')).json()); + expect(config.publicKey).toBeNull(); + } finally { + await plain.close(); + } + }); +}); + +describe('POST /push/subscribe', () => { + it('needs a session', async () => { + const response = await h.app.request('/api/push/subscribe', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(SUBSCRIPTION), + }); + expect(response.status).toBe(401); + }); + + it('rejects a body that is not a subscription', async () => { + const response = await json('/api/push/subscribe', { + method: 'POST', + body: JSON.stringify({ endpoint: 'not-a-url' }), + }); + expect(response.status).toBe(400); + }); + + it('stores one row per endpoint, however many times the browser subscribes', async () => { + expect((await json('/api/push/subscribe', { method: 'POST', body: JSON.stringify(SUBSCRIPTION) })).status).toBe(200); + expect( + ( + await json('/api/push/subscribe', { + method: 'POST', + body: JSON.stringify({ ...SUBSCRIPTION, keys: { ...SUBSCRIPTION.keys, auth: 'rotated' } }), + }) + ).status, + ).toBe(200); + + const rows = await h.deps.handle.db + .selectFrom('push_subscriptions') + .selectAll() + .where('endpoint', '=', SUBSCRIPTION.endpoint) + .execute(); + + expect(rows).toHaveLength(1); + // The second subscribe refreshed the keys rather than leaving a stale row behind. + expect(JSON.parse(rows[0]?.keys ?? '{}')).toEqual({ ...SUBSCRIPTION.keys, auth: 'rotated' }); + }); + + it('forgets the subscription when the browser gives it up', async () => { + const response = await json('/api/push/subscribe', { + method: 'DELETE', + body: JSON.stringify({ endpoint: SUBSCRIPTION.endpoint }), + }); + expect(response.status).toBe(200); + + const rows = await h.deps.handle.db + .selectFrom('push_subscriptions') + .selectAll() + .where('endpoint', '=', SUBSCRIPTION.endpoint) + .execute(); + expect(rows).toHaveLength(0); + }); +}); + +describe('a subscription the push service says is gone', () => { + it('is deleted, and does not count as a delivery', async () => { + const db = h.deps.handle.db; + const user = await db + .selectFrom('user') + .select('id') + .where('email', '=', 'maria@demo.local') + .executeTakeFirstOrThrow(); + + await db + .updateTable('notification_prefs') + .set({ push_enabled: 1 }) + .where('user_id', '=', user.id) + .execute(); + await db + .insertInto('push_subscriptions') + .values({ + id: 'wiped-phone', + user_id: user.id, + endpoint: 'https://push.example.test/subscription/wiped', + keys: JSON.stringify(SUBSCRIPTION.keys), + created_at: new Date().toISOString(), + }) + .execute(); + + // A phone that was reset: the push service answers 410 for good. + const channels: Channels = { + push: async (targets) => ({ gone: targets.map((target) => target.endpoint) }), + email: null, + telegram: null, + }; + + const result = await sendNotification( + { db, channels }, + { + userId: user.id, + notification: { + kind: 'declaration_ready', + form: '120', + period: '2026-08', + declarationId: 'whatever', + }, + }, + ); + + expect(result.delivered).not.toContain('push'); + + const rows = await db.selectFrom('push_subscriptions').selectAll().where('user_id', '=', user.id).execute(); + expect(rows).toHaveLength(0); + }); +}); + +describe('the VAPID subject', () => { + function channelsFor(overrides: Record) { + const parsed = parseEnv({ ...TEST_ENV, ...VAPID, ...overrides }); + if (!parsed.ok || !parsed.env) throw new Error(parsed.message); + return createChannels(parsed.env); + } + + /** + * The regression: web-push throws on a subject that is not https: or mailto:, and + * APP_PUBLIC_URL is http in development. Taking it unchecked killed the API at boot. + */ + it('switches push off rather than crashing when there is no usable subject', () => { + expect(channelsFor({ APP_PUBLIC_URL: 'http://localhost:3005' }).push).toBeNull(); + }); + + it('takes an https APP_PUBLIC_URL', () => { + expect(channelsFor({ APP_PUBLIC_URL: 'https://impuestos.example' }).push).not.toBeNull(); + }); + + it('falls back to the sending address', () => { + expect( + channelsFor({ APP_PUBLIC_URL: 'http://localhost:3005', SMTP_FROM: 'avisos@impuestos.example' }) + .push, + ).not.toBeNull(); + }); + + it('prefers an explicit subject over both', () => { + expect( + channelsFor({ + APP_PUBLIC_URL: 'http://localhost:3005', + PUSH_VAPID_SUBJECT: 'mailto:soporte@impuestos.example', + }).push, + ).not.toBeNull(); + }); +}); diff --git a/apps/api/src/http/routes/push.ts b/apps/api/src/http/routes/push.ts new file mode 100644 index 0000000..f21cb09 --- /dev/null +++ b/apps/api/src/http/routes/push.ts @@ -0,0 +1,74 @@ +import { PushSubscriptionInput } from '@impuestos/contracts'; +import { Hono } from 'hono'; +import { uuidv7 } from 'uuidv7'; +import type { AppDeps, AppEnv } from '../context'; +import { HttpError } from '../errors'; +import { requireUser } from '../middleware'; + +/** + * Browser push subscriptions. The key a browser needs to subscribe is public by + * definition, so reading it needs no session; storing a subscription does. + */ +export function pushRoutes(deps: AppDeps): Hono { + const routes = new Hono(); + const db = deps.handle.db; + + routes.get('/config', (c) => + c.json({ publicKey: deps.env.PUSH_VAPID_PUBLIC_KEY ?? null }), + ); + + routes.post('/subscribe', async (c) => { + const user = requireUser(c); + const parsed = PushSubscriptionInput.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) throw new HttpError('validation_error', { detail: parsed.error.issues }); + + // One row per endpoint: re-subscribing the same browser refreshes its keys rather than + // leaving a second row that will be pushed to twice. + const existing = await db + .selectFrom('push_subscriptions') + .select('id') + .where('endpoint', '=', parsed.data.endpoint) + .executeTakeFirst(); + + if (existing) { + await db + .updateTable('push_subscriptions') + .set({ user_id: user.id, keys: JSON.stringify(parsed.data.keys) }) + .where('id', '=', existing.id) + .execute(); + } else { + await db + .insertInto('push_subscriptions') + .values({ + id: uuidv7(), + user_id: user.id, + endpoint: parsed.data.endpoint, + keys: JSON.stringify(parsed.data.keys), + created_at: new Date().toISOString(), + }) + .execute(); + } + + return c.json({ ok: true } as const); + }); + + routes.delete('/subscribe', async (c) => { + const user = requireUser(c); + const body: unknown = await c.req.json().catch(() => null); + const endpoint = + typeof body === 'object' && body !== null && 'endpoint' in body + ? (body as { endpoint: unknown }).endpoint + : null; + if (typeof endpoint !== 'string') throw new HttpError('validation_error', { field: 'endpoint' }); + + await db + .deleteFrom('push_subscriptions') + .where('user_id', '=', user.id) + .where('endpoint', '=', endpoint) + .execute(); + + return c.json({ ok: true } as const); + }); + + return routes; +} diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 72086df..6cdee7b 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -44,6 +44,8 @@ const EnvObject = z.object({ PUSH_VAPID_PUBLIC_KEY: optionalString, PUSH_VAPID_PRIVATE_KEY: optionalString, + /** Contact for the push service. Must be an https: or mailto: URL, per RFC 8292. */ + PUSH_VAPID_SUBJECT: optionalString, SMTP_HOST: optionalString, SMTP_PORT: z.coerce.number().int().positive().max(65535).default(587), diff --git a/apps/api/src/modules/jobs/sweeps.test.ts b/apps/api/src/modules/jobs/sweeps.test.ts index d77a89c..66d3361 100644 --- a/apps/api/src/modules/jobs/sweeps.test.ts +++ b/apps/api/src/modules/jobs/sweeps.test.ts @@ -19,6 +19,7 @@ function recordingChannels(): Channels & { sent: { channel: string; message: Out sent, push: async (_targets, message) => { sent.push({ channel: 'push', message }); + return { gone: [] }; }, email: async (_to, _subject, message) => { sent.push({ channel: 'email', message }); diff --git a/apps/api/src/modules/notifications/channels.ts b/apps/api/src/modules/notifications/channels.ts index 717d651..db36198 100644 --- a/apps/api/src/modules/notifications/channels.ts +++ b/apps/api/src/modules/notifications/channels.ts @@ -19,7 +19,8 @@ export interface PushTarget { * the fan-out skips it (FLOWS.md section 9). */ export interface Channels { - push: ((targets: PushTarget[], message: OutgoingMessage) => Promise) | null; + /** Resolves with the endpoints the push service says are gone, so they can be pruned. */ + push: ((targets: PushTarget[], message: OutgoingMessage) => Promise<{ gone: string[] }>) | null; email: ((to: string, subject: string, message: OutgoingMessage) => Promise) | null; telegram: ((chatId: string, message: OutgoingMessage) => Promise) | null; } @@ -32,10 +33,31 @@ export function createChannels(env: Env): Channels { }; } +/** + * RFC 8292 requires the VAPID subject to be an `https:` or a `mailto:` URL, and web-push + * throws rather than warns when it is not. `APP_PUBLIC_URL` is http in development, so + * taking it unchecked crashes the API at boot on any machine that has push keys set. + */ +function vapidSubject(env: Env): string | null { + if (env.PUSH_VAPID_SUBJECT) return env.PUSH_VAPID_SUBJECT; + if (env.APP_PUBLIC_URL.startsWith('https:')) return env.APP_PUBLIC_URL; + if (env.SMTP_FROM) return `mailto:${env.SMTP_FROM.replace(/^.*.*$/, '')}`; + return null; +} + function createPush(env: Env): Channels['push'] { if (!env.PUSH_VAPID_PUBLIC_KEY || !env.PUSH_VAPID_PRIVATE_KEY) return null; - webpush.setVapidDetails(env.APP_PUBLIC_URL, env.PUSH_VAPID_PUBLIC_KEY, env.PUSH_VAPID_PRIVATE_KEY); + const subject = vapidSubject(env); + if (!subject) { + // A misconfigured optional channel switches itself off. It does not take the API down. + console.warn( + '[boot] push is configured but PUSH_VAPID_SUBJECT is not, and APP_PUBLIC_URL is not https. Push is off.', + ); + return null; + } + + webpush.setVapidDetails(subject, env.PUSH_VAPID_PUBLIC_KEY, env.PUSH_VAPID_PRIVATE_KEY); return async (targets, message) => { const payload = JSON.stringify({ @@ -45,11 +67,24 @@ function createPush(env: Env): Channels['push'] { }); // One dead subscription must not stop the others: a phone that was wiped is normal. - await Promise.allSettled( + const results = await Promise.allSettled( targets.map((target) => webpush.sendNotification({ endpoint: target.endpoint, keys: target.keys }, payload), ), ); + + // 404 and 410 mean the browser threw the subscription away. Anything else is a + // transient failure and the row stays: a network blip is not a reason to stop + // notifying someone forever. + const gone: string[] = []; + results.forEach((result, index) => { + const endpoint = targets[index]?.endpoint; + if (!endpoint || result.status !== 'rejected') return; + const status = (result.reason as { statusCode?: number } | undefined)?.statusCode; + if (status === 404 || status === 410) gone.push(endpoint); + }); + + return { gone }; }; } diff --git a/apps/api/src/modules/notifications/send.ts b/apps/api/src/modules/notifications/send.ts index b91916c..2e59c6a 100644 --- a/apps/api/src/modules/notifications/send.ts +++ b/apps/api/src/modules/notifications/send.ts @@ -57,8 +57,13 @@ export async function sendNotification( }); if (targets.length > 0) { - await deps.channels.push(targets, message); - delivered.push('push'); + const { gone } = await deps.channels.push(targets, message); + if (gone.length > 0) { + await deps.db.deleteFrom('push_subscriptions').where('endpoint', 'in', gone).execute(); + } + // Everything the push service did not reject is a delivery. A wiped phone is not a + // failure of this send, it is a subscription that no longer exists. + if (gone.length < targets.length) delivered.push('push'); } } diff --git a/apps/web/app/[locale]/(app)/bandeja/bandeja.tsx b/apps/web/app/[locale]/(app)/bandeja/bandeja.tsx index 7bf33bd..29bf38b 100644 --- a/apps/web/app/[locale]/(app)/bandeja/bandeja.tsx +++ b/apps/web/app/[locale]/(app)/bandeja/bandeja.tsx @@ -7,13 +7,16 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Check, X } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { CategoryPicker } from '@/components/category-picker'; +import { ConfettiMoment } from '@/components/confetti-moment'; import { Button, buttonClasses } from '@/components/ui/button'; +import { CheckDraw } from '@/components/ui/check-draw'; import { Card } from '@/components/ui/card'; import { EmptyState } from '@/components/ui/empty-state'; import { Skeleton } from '@/components/ui/skeleton'; import { Link } from '@/i18n/navigation'; import { useT } from '@/i18n/t'; import { api } from '@/lib/api'; +import { DURATION, EASE, gsap, usePrefersReducedMotion } from '@/lib/motion'; import { cn } from '@/lib/utils'; /** Past this many pixels, letting go commits the swipe. */ @@ -28,8 +31,13 @@ export function Bandeja({ locale }: { locale: string }) { const queryClient = useQueryClient(); const [index, setIndex] = useState(0); const [picking, setPicking] = useState(false); - const [drag, setDrag] = useState(0); - const [dragging, setDragging] = useState(false); + const [celebrating, setCelebrating] = useState(false); + const reduced = usePrefersReducedMotion(); + + // The drag lives on the element rather than in state: a re-render per pointer move is + // both slower and a fight with GSAP over who owns the transform. + const card = useRef(null); + const nextCard = useRef(null); const dragStart = useRef(null); const pending = useQuery({ @@ -37,11 +45,23 @@ export function Bandeja({ locale }: { locale: string }) { queryFn: ({ signal }) => api.listDocuments({ status: 'needs_review' }, signal), }); + /** + * Whether anything has ever been confirmed. FLOWS.md A6 makes the first confirmed + * document the moment worth celebrating, and it is only the first one: the count is read + * before the confirm so the answer is about what the user had, not what they now have. + */ + const confirmed = useQuery({ + queryKey: ['documents', { status: 'confirmed' }], + queryFn: ({ signal }) => api.listDocuments({ status: 'confirmed' }, signal), + }); + const nothingConfirmedYet = confirmed.data?.total === 0; + const documents = pending.data?.items ?? []; // Confirming the last card shortens the list under us. Clamping here rather than in an // effect keeps the render consistent and avoids a cascading update. const safeIndex = Math.min(index, Math.max(0, documents.length - 1)); const current = documents[safeIndex]; + const next = documents[safeIndex + 1]; const invalidate = useCallback( () => queryClient.invalidateQueries({ queryKey: ['documents'] }), @@ -51,7 +71,7 @@ export function Bandeja({ locale }: { locale: string }) { const confirm = useMutation({ mutationFn: (id: string) => api.confirmDocument(id), onSuccess: async () => { - setDrag(0); + if (nothingConfirmedYet) setCelebrating(true); await invalidate(); }, }); @@ -59,10 +79,7 @@ export function Bandeja({ locale }: { locale: string }) { const reject = useMutation({ mutationFn: (args: { id: string; reason: 'not_mine' | 'duplicate' | 'other' }) => api.rejectDocument(args.id, { reason: args.reason }), - onSuccess: async () => { - setDrag(0); - await invalidate(); - }, + onSuccess: invalidate, }); const reclassify = useMutation({ @@ -74,14 +91,55 @@ export function Bandeja({ locale }: { locale: string }) { }, }); + /** + * Sends the card off the way it was pushed, brings the one behind it forward, and only + * then does the thing (FLOWS.md B4: fly out on commit, next card scales up). Under + * reduced motion the action happens immediately and nothing moves. + */ + const flyOut = useCallback( + (direction: 1 | -1, run: () => void) => { + const node = card.current; + if (reduced || !node) { + run(); + return; + } + gsap.to(node, { + x: direction * 520, + rotation: direction * 16, + autoAlpha: 0, + duration: DURATION.base, + ease: 'power2.in', + onComplete: () => { + gsap.set(node, { clearProps: 'all' }); + run(); + }, + }); + if (nextCard.current) { + gsap.to(nextCard.current, { scale: 1, opacity: 1, duration: DURATION.base, ease: EASE }); + } + }, + [reduced], + ); + + const settle = useCallback(() => { + const node = card.current; + if (!node) return; + if (reduced) { + gsap.set(node, { x: 0, rotation: 0 }); + return; + } + gsap.to(node, { x: 0, rotation: 0, duration: DURATION.base, ease: EASE }); + }, [reduced]); + // Desktop keyboard mirrors of every gesture (FLOWS.md B4). useEffect(() => { function onKey(event: KeyboardEvent) { if (!current || picking) return; if (event.key === 'j') setIndex(Math.min(safeIndex + 1, documents.length - 1)); else if (event.key === 'k') setIndex(Math.max(safeIndex - 1, 0)); - else if (event.key === 'Enter') confirm.mutate(current.id); - else if (event.key === 'x' || event.key === 'X') reject.mutate({ id: current.id, reason: 'other' }); + else if (event.key === 'Enter') flyOut(1, () => confirm.mutate(current.id)); + else if (event.key === 'x' || event.key === 'X') + flyOut(-1, () => reject.mutate({ id: current.id, reason: 'other' })); else if (/^[1-8]$/.test(event.key)) { const category = IRP_CATEGORIES[Number(event.key) - 1]; if (category) reclassify.mutate({ id: current.id, irpCategory: category }); @@ -91,7 +149,7 @@ export function Bandeja({ locale }: { locale: string }) { window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [current, documents.length, safeIndex, picking, confirm, reject, reclassify]); + }, [current, documents.length, safeIndex, picking, confirm, reject, reclassify, flyOut]); if (pending.isPending) { return ( @@ -124,7 +182,7 @@ export function Bandeja({ locale }: { locale: string }) {
} + icon={} title={t('bandeja.empty')} body={t('bandeja.emptyBody')} action={ @@ -143,6 +201,8 @@ export function Bandeja({ locale }: { locale: string }) { return (
+ +

{t('bandeja.title')}

@@ -150,31 +210,45 @@ export function Bandeja({ locale }: { locale: string }) {
+
+ {/* + The shoulder of the next card, so the stack is visible and committing this one + has somewhere to go. A shoulder rather than a whole card behind: the front card's + height depends on its content, and a full card would sit entirely hidden behind a + tall one and entirely exposed behind a short one. + */} + {next ? ( +
+ ) : null} + { dragStart.current = event.clientX; - setDragging(true); event.currentTarget.setPointerCapture(event.pointerId); }} onPointerMove={(event) => { if (dragStart.current === null) return; - setDrag(event.clientX - dragStart.current); + const offset = event.clientX - dragStart.current; + gsap.set(card.current, { x: offset, rotation: offset / 40 }); }} - onPointerUp={() => { - const offset = drag; + onPointerUp={(event) => { + const start = dragStart.current; dragStart.current = null; - setDragging(false); - if (offset > COMMIT_PX) confirm.mutate(current.id); + if (start === null) return; + const offset = event.clientX - start; + + if (offset > COMMIT_PX) flyOut(1, () => confirm.mutate(current.id)); else if (offset < -COMMIT_PX) { + settle(); setPicking(true); - setDrag(0); - } else setDrag(0); + } else settle(); }} >
@@ -208,6 +282,7 @@ export function Bandeja({ locale }: { locale: string }) { ) : null} +
{picking ? ( @@ -229,7 +304,7 @@ export function Bandeja({ locale }: { locale: string }) { size="lg" block disabled={confirm.isPending} - onClick={() => confirm.mutate(current.id)} + onClick={() => flyOut(1, () => confirm.mutate(current.id))} > {t('bandeja.confirm')} @@ -239,7 +314,7 @@ export function Bandeja({ locale }: { locale: string }) { type="button" variant="secondary" disabled={reject.isPending} - onClick={() => reject.mutate({ id: current.id, reason: 'other' })} + onClick={() => flyOut(-1, () => reject.mutate({ id: current.id, reason: 'other' }))} > {t('scan.result.discard')} diff --git a/apps/web/app/[locale]/(app)/declaraciones/[id]/declaration-detail.tsx b/apps/web/app/[locale]/(app)/declaraciones/[id]/declaration-detail.tsx index 4b3280f..75c3363 100644 --- a/apps/web/app/[locale]/(app)/declaraciones/[id]/declaration-detail.tsx +++ b/apps/web/app/[locale]/(app)/declaraciones/[id]/declaration-detail.tsx @@ -4,9 +4,11 @@ import { isApiError, type DeclarationDto } from '@impuestos/contracts'; import { formatDateLong, formatGs, formatGsAmount, type Locale } from '@impuestos/i18n'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useRef, useState } from 'react'; +import { ConfettiMoment } from '@/components/confetti-moment'; import { FormPreview } from '@/components/form-preview'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; +import { Reveal } from '@/components/ui/reveal'; import { Skeleton } from '@/components/ui/skeleton'; import { useT } from '@/i18n/t'; import { api } from '@/lib/api'; @@ -22,6 +24,7 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string } const queryClient = useQueryClient(); const confirmDialog = useRef(null); const [staleWarning, setStaleWarning] = useState(null); + const [celebrating, setCelebrating] = useState(false); const declaration = useQuery({ queryKey: ['declarations', id], @@ -32,6 +35,8 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string } const approve = useMutation({ mutationFn: () => api.approveDeclaration(id), onSuccess: async () => { + // One of the three moments FLOWS.md section 8 allows a celebration. + setCelebrating(true); setStaleWarning(null); confirmDialog.current?.close(); await queryClient.invalidateQueries({ queryKey: ['declarations'] }); @@ -75,7 +80,9 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string } const officialTitle = `Formulario ${data.formCode}`; return ( -
+ + +

{officialTitle}

{data.period}

@@ -172,7 +179,7 @@ export function DeclarationDetail({ id, locale }: { id: string; locale: string } {t('decl.approvedAt', { date: formatDateLong(locale as Locale, data.approvedAt.slice(0, 10)) })}

) : null} -
+
); } diff --git a/apps/web/app/[locale]/(app)/declaraciones/[id]/filing-checklist.tsx b/apps/web/app/[locale]/(app)/declaraciones/[id]/filing-checklist.tsx index cdd4bd2..9d274d6 100644 --- a/apps/web/app/[locale]/(app)/declaraciones/[id]/filing-checklist.tsx +++ b/apps/web/app/[locale]/(app)/declaraciones/[id]/filing-checklist.tsx @@ -5,6 +5,7 @@ import { formatDateLong, formatGs, type Locale } from '@impuestos/i18n'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Check, Copy } from 'lucide-react'; import { useState } from 'react'; +import { CelebrationCanvas } from '@/components/canvas/celebration-canvas'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { useT } from '@/i18n/t'; @@ -46,8 +47,10 @@ export function FilingChecklist({ if (filed) { return ( - - {/* A short celebration belongs here (FLOWS.md D3); the motion pass is phase 7. */} + + {/* The second and last canvas spot (FLOWS.md D3), only in the moment it happens: + a reload of an already filed declaration is not a new success. */} + {markFiled.isSuccess ? : null}

{t('decl.filed.title')}

diff --git a/apps/web/app/[locale]/(app)/escanear/scan-screen.tsx b/apps/web/app/[locale]/(app)/escanear/scan-screen.tsx index a19bcaf..e1aedec 100644 --- a/apps/web/app/[locale]/(app)/escanear/scan-screen.tsx +++ b/apps/web/app/[locale]/(app)/escanear/scan-screen.tsx @@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Link, useRouter } from '@/i18n/navigation'; import { useT } from '@/i18n/t'; import { api } from '@/lib/api'; +import { enqueueCapture, listQueue, subscribeToQueue } from '@/lib/offline-queue'; import { captureFrame, decodeQr, decodeQrFromVideo } from '@/lib/qr'; import { cn } from '@/lib/utils'; @@ -35,6 +36,16 @@ export function ScanScreen({ locale }: { locale: string }) { const [showNoQrHint, setShowNoQrHint] = useState(false); const [torchOn, setTorchOn] = useState(false); const [outcome, setOutcome] = useState(null); + // Whether anything of this reader's is still waiting to be sent. It follows the queue + // rather than being set once, so the notice goes away when the capture actually lands. + const [queued, setQueued] = useState(false); + + useEffect(() => { + const check = () => void listQueue().then((items) => setQueued(items.length > 0)); + const unsubscribe = subscribeToQueue(check); + void Promise.resolve().then(check); + return unsubscribe; + }, []); const upload = useMutation({ mutationFn: async (input: { file: File; qrPayload: string | null }) => { @@ -54,6 +65,16 @@ export function ScanScreen({ locale }: { locale: string }) { const submit = useCallback( (file: File, qrPayload: string | null) => { if (busy.current) return; + + /** + * Offline, the capture is kept rather than lost (FLOWS.md B2). The chip in the shell + * sends it the moment there is a connection, so this screen can say so and move on. + */ + if (!navigator.onLine) { + void enqueueCapture(file, qrPayload); + return; + } + busy.current = true; upload.mutate({ file, qrPayload }, { onSettled: () => (busy.current = false) }); }, @@ -200,6 +221,12 @@ export function ScanScreen({ locale }: { locale: string }) {
) : null} + {queued ? ( +

+ {t('offline.queued')} +

+ ) : null} + {upload.isError ? (

{t('common.error.generic')} diff --git a/apps/web/app/[locale]/(app)/inicio/dashboard.tsx b/apps/web/app/[locale]/(app)/inicio/dashboard.tsx index 6c77016..37346fa 100644 --- a/apps/web/app/[locale]/(app)/inicio/dashboard.tsx +++ b/apps/web/app/[locale]/(app)/inicio/dashboard.tsx @@ -8,6 +8,7 @@ import { TraceableNumber } from '@/components/traceable-number'; import { Button, buttonClasses } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { EmptyState } from '@/components/ui/empty-state'; +import { Reveal } from '@/components/ui/reveal'; import { Skeleton } from '@/components/ui/skeleton'; import { Link } from '@/i18n/navigation'; import { useT } from '@/i18n/t'; @@ -53,7 +54,7 @@ export function Dashboard({ locale, fullName }: { locale: Locale; fullName: stri const nothingYet = !data.hasDocuments; return ( -

+

{t('home.title')}

{fullName}

@@ -89,7 +90,7 @@ export function Dashboard({ locale, fullName }: { locale: Locale; fullName: stri ) : null} -
+
); } diff --git a/apps/web/app/[locale]/(app)/layout.tsx b/apps/web/app/[locale]/(app)/layout.tsx index 439df7c..8cb41b0 100644 --- a/apps/web/app/[locale]/(app)/layout.tsx +++ b/apps/web/app/[locale]/(app)/layout.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from 'react'; import { AppShell } from '@/components/app-shell'; import { LanguageSwitcher } from '@/components/language-switcher'; +import { OfflineSync } from '@/components/offline-sync'; +import { PwaProvider } from '@/components/pwa-provider'; import { Link } from '@/i18n/navigation'; import { useT } from '@/i18n/t'; @@ -22,6 +24,8 @@ export default function AppLayout({ children }: { children: ReactNode }) { {/* Bottom padding clears the tab bar and the scan button above it. */}
{children}
+ +
); } diff --git a/apps/web/app/[locale]/(app)/perfil/notifications-section.tsx b/apps/web/app/[locale]/(app)/perfil/notifications-section.tsx index 163f572..109ed1a 100644 --- a/apps/web/app/[locale]/(app)/perfil/notifications-section.tsx +++ b/apps/web/app/[locale]/(app)/perfil/notifications-section.tsx @@ -3,6 +3,8 @@ import type { ProfileDto } from '@impuestos/contracts'; import { SUPPORTED_LOCALES, type Locale } from '@impuestos/i18n'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { PushOptIn } from '@/components/push-optin'; +import { ThemeToggle } from '@/components/theme-toggle'; import { Card } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { Switch } from '@/components/ui/switch'; @@ -83,11 +85,14 @@ export function NotificationsSection({ profile }: { profile: ProfileDto }) { - {/* Push and Telegram are hidden until they are configured (FLOWS.md section 9). - They are wired with the notification channels in phase 4. */} + {/* Push shows itself only where it can work; Telegram is linked from the bot + rather than here (FLOWS.md section 9: unconfigured channels stay hidden). */} +
) : null} + +
{t('common.language')} { + const next = event.target.value as Theme; + setTheme(next); + applyTheme(next); + }} + > + {THEMES.map((option) => ( + + ))} + +
+ ); +} diff --git a/apps/web/src/components/traceable-number.tsx b/apps/web/src/components/traceable-number.tsx index 0250c17..b698851 100644 --- a/apps/web/src/components/traceable-number.tsx +++ b/apps/web/src/components/traceable-number.tsx @@ -3,12 +3,13 @@ import type { TraceKind } from '@impuestos/contracts'; import { formatDateShort, formatGs, type Locale } from '@impuestos/i18n'; import { useQuery } from '@tanstack/react-query'; -import { useRef } from 'react'; +import { useCallback, useRef } from 'react'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { Link } from '@/i18n/navigation'; import { useT } from '@/i18n/t'; import { api } from '@/lib/api'; +import { DURATION, EASE, gsap, useCountUp, usePrefersReducedMotion } from '@/lib/motion'; import { cn } from '@/lib/utils'; /** @@ -30,6 +31,24 @@ export function TraceableNumber({ }) { const t = useT(); const dialog = useRef(null); + const reduced = usePrefersReducedMotion(); + + // The headline figure rolls up on first paint and on every change (FLOWS.md C1). + const format = useCallback((amount: number) => formatGs(amount), []); + const figure = useCountUp(value, format); + + function open() { + const node = dialog.current; + if (!node) return; + node.showModal(); + if (reduced) return; + // Spring-ish, no bounce overdose (FLOWS.md section 8). + gsap.fromTo( + node, + { autoAlpha: 0, y: 12, scale: 0.98 }, + { autoAlpha: 1, y: 0, scale: 1, duration: DURATION.base, ease: EASE }, + ); + } const trace = useQuery({ queryKey: ['dashboard', 'trace', kind], @@ -43,7 +62,7 @@ export function TraceableNumber({ type="button" onClick={() => { void trace.refetch(); - dialog.current?.showModal(); + open(); }} className={cn( 'tnum text-left underline decoration-dotted underline-offset-4', @@ -52,7 +71,7 @@ export function TraceableNumber({ className, )} > - {formatGs(value)} + {formatGs(value)} ) { +/** `ref` is a plain prop in React 19, so a caller can hand the card to GSAP. */ +export function Card({ + className, + ref, + ...props +}: HTMLAttributes & { ref?: Ref }) { return (