diff --git a/docs/ENGAGEMENT_NEXT_WAVE_2026-11-05.md b/docs/ENGAGEMENT_NEXT_WAVE_2026-11-05.md new file mode 100644 index 00000000..f2c52c70 --- /dev/null +++ b/docs/ENGAGEMENT_NEXT_WAVE_2026-11-05.md @@ -0,0 +1,39 @@ +# Toolify365 — engagement improvement follow-up (2026-11-05) + +## Implemented in draft PR #39 +- Recently visited links on tool pages, limited to 3; now exclude both current/unknown/duplicate and any destination already shown by the existing related-tools row. +- Percentage-calculator A/B comparison and explicit local draft save/restore/delete, 7-day expiry, 17-language text. +- Analytics: `recent_tool_click` and `scenario_action` (IDs only; no user-entered numbers). +- Standalone verification: pure-module strict TypeScript passed; 29 earlier independent assertions, 9 shared-state checks and 6 recent-link-selection checks passed separately. **Not full project Vitest/Next build**. + +## Current release blocks +1. Full checkout cannot be fetched from this environment due to DNS resolution of github.com. Run `bash scripts/check-engagement-release.sh` on an owner-accessible clone. +2. Manual en/ja desktop/mobile keyboard, share-link, unavailable browser storage, expired draft, consent and GA4 DebugView QA. +3. Merge only after those checks; VPS deployment and post-deploy smoke test belong to owner. +4. HYPD live-browser and Lighthouse endpoints were not available on the connected plan, so no real mobile performance score was obtained. HYPD GA4 account summaries returned no **selected** properties; do not claim measured engagement improvements. + +## Additional work worth doing after release, in impact order +**P0 — use evidence, not time-on-page alone.** Establish last 28 days vs preceding 28 days: engaged sessions, engagement rate, average engagement time per session, pages/session, `calculate`, `recent_tool_click`, `related_click`, `scenario_action`, affiliate click and revenue. Break down by tool landing page, device, locale and source. Deduplicate returning navigation and avoid counting passive tabs as improvement. Register necessary GA4 custom dimensions (event-scoped `destination_tool`, `action`) if they are absent; verify consent first. + +**P1 — test on high-intent tools only.** Pick the top 5 landing pages from GA4 with enough traffic and the lowest engaged-session ratio; verify tool result is visible in first mobile viewport and no ad obscures it. Prioritize task completion, then a single logically related next action. Do not add generic link blocks or timers just to inflate average duration. + +**P2 — contextual workflows.** After successful % calculator comparison, test task-specific save/compare for loan, tip and other numeric tools only if a privacy review confirms their inputs are safe for local persistence. For free-text, health, financial identifiers or uploads, avoid blanket auto-save. + +**P3 — speed and A/B rollout.** Measure mobile Lighthouse LCP/INP/CLS, test reserved ad space and lazy loading, validate language navigation and ad placement. Roll out behind a tested feature gate or short evaluation window when analytics/sample size supports it. Roll back if tool completion or mobile performance worsens. + +## Operational release policy +PR #39 stays draft / NO-GO until full build and real-device QA succeed. This document is a plan for further implementation, not a claim of production changes, conversion lifts or GA4 access. + +## Additional implementation: result-first navigation (2026-11-05) +- `ToolFrame` now shows the existing related tool row and the new recent tool row **immediately after the workspace, before the below-result and banner ad units**. Both ad units, the in-article ad, and sidebar ad are retained; this is an ordering change, not an ad removal. +- The previous related/recent deduplication means suggestions maximize distinct follow-up actions. +- Added `site/src/components/tools/ToolFrame.order.test.ts` to enforce source-level order and retained ad components. This test was committed but has NOT been run inside the project's Vitest environment. +- GitHub code readback confirmed the order after commit; visual, iPhone, ad placement and RPM impact still require real device QA and post-release measurement. +- `percentage-calculator` was verified present in `MANUAL_SHARE_SLUGS`: its own `useShareableState` intentionally disables generic URL writing from `useAutoShareableState`, so this PR does not need to add a second serialization path. +- GA4 cannot currently be queried with the selected connection: HYPD reports connected GA4 accounts but no selected property. Select the Toolify365 property before any baseline analysis; do not infer current engagement from other businesses. + +## ShareBar URL polling optimization (2026-11-05) +- Replaced unconditional one-second URL polling with change events and history/BFCache resynchronization. +- Both manual and automatic share hooks now notify the result-link control immediately after replaceState. +- Added source-level regression tests in ShareBar.events.test.ts; full project Vitest and browser tests not run. +- Validate result-link availability after edits, navigation and tab restoration before release. No performance uplift claimed. diff --git a/docs/GROWTH_RELEASE_GATE_2026-11-05.md b/docs/GROWTH_RELEASE_GATE_2026-11-05.md new file mode 100644 index 00000000..6ccb033b --- /dev/null +++ b/docs/GROWTH_RELEASE_GATE_2026-11-05.md @@ -0,0 +1,86 @@ +# Toolify365 Growth Release Gate — 2026-11-05 + +Related: #37, PR #38. No GitHub Actions are required or authorized for this rollout. + +## Existing SEO safeguards (do not duplicate) +- `src/app/sitemap.ts`: generates only URLs selected by `isIndexableIn` / `indexableLocalesFor`. +- `src/app/robots.ts`: publishes robots/sitemap configuration. +- `src/lib/seo/metadata.ts`: canonical, robots and hreflang. +- `src/lib/tools/registry.ts`: demand-ranked core index allowlist + selective locale exceptions. +- `scripts/audit-seo.ts`, `scripts/report-index-policy.ts`, `scripts/indexnow.ts`: existing audits and index notification. + +**Do not** bulk-index all 223 tools × 17 locales. GSC index coverage is NOT the same as the count of URLs intended for indexing. + +## Local verification from repository root + +```bash +cd site +npm ci +npm run typecheck +npm run test +npm run validate +npm run audit:seo +npm run audit:i18n +npm run audit:index-policy -- --json +npm run build +``` + +These checks need a machine with dependencies and network/required build environment. If any check fails, do not merge or deploy. Follow existing `prebuild` exchange-rate freshness policy; do not bypass it to force a green build. + +## Browser QA before production +1. Check en and ja pages `/tools/character-frequency` and `/tools/reverse-text-generator`: free software CTA appears below tool and links to Text Batch Studio with correct locale. +2. Check `/tools/csv-json-converter` and `/tools/json-formatter`: only currently available CSV Cleaner Pro and JSON Studio destinations appear. +3. Confirm a tool with no mapping does not show an empty software panel. +4. On mobile and keyboard navigation, confirm CTA focus, labels and layout. +5. Check `/software/{csv-cleaner-pro,json-studio,text-batch-studio}` processing / downloading still work, and failed input still gives the previous error. +6. Consent/debug QA: `software_click` fires on CTA, `software_process` on process success/failure and `software_download` on a download click. No user-entered text, file name or file contents are sent. Without GA configuration events are safe no-ops. +7. Verify live canonical host is `https://toolify365.com`, and check HTTP 200, robots/sitemap and hreflang reciprocal URLs manually. Search-result snippets can lag deployment. + +## GA4 definitions +Create event-scoped custom dimensions (where not already present): `tool_slug`, `locale`, `destination`, `software_slug`. Do not register redundant fields. Analyze: +- Organic landing sessions → `calculate` → `software_click` +- `software_click` → `software_process` successful +- `software_process` → `software_download` +Count users/sessions when possible, not only raw events. Exclude internal traffic. Not all click-through events guarantee successful navigation or file download. + +## Search prioritization +- Obtain last 28 finalized days and prior 28 days from GSC / Bing, grouped by page + query + locale/country + device. +- Work on high-impression, low-CTR indexed pages first; ensure unique title, accurate introduction, examples and FAQ. +- Expand `INDEX_EXCEPTIONS` only for verified demand with localized content; 5–10 pairs at a time, assess after 28 days, rollback if indexing/crawl quality degrades. +- Do not automatically post marketing links to unrelated communities or create scaled thin pages. + +## Deployment and rollback +- After all gates pass, merge PR #38 and deploy by the site's owner-managed process. +- Verify direct URLs and GA4 DebugView, then submit **changed, valid, canonical** URLs using existing IndexNow script (not Google). +- Record release commit, timestamp, and post-release GA4/GSC/Bing baseline. +- If UI/processing or analytics regress, revert PR #38 and redeploy; do not edit the sitemap allowlist as part of rollback unless it changed. + +## Current evidence +Source-only reviews are not equivalent to running the above commands. The connected GSC access returned a 3-day sample (2026-10-31 to 2026-11-02) with no suggested opportunities; it cannot establish a 28-day comparison or zero traffic. + +## Post-deployment automated smoke check (new) +Run only **after** a successful owner deployment: +```bash +cd site +SITE_URL=https://toolify365.com npm run audit:live-growth +``` +This performs read-only HTTP checks on key en/ja tool and software pages, expected contextual CTA HTML, canonical presence, robots and sitemap. HTTP connectivity is required; a failure from a restricted runner is NOT proof that the production site is down. This script is not a substitute for browser interaction tests, GA4 DebugView, live Search Console URL Inspection or a local production build. + +## Extended release safeguards (2026-11-05) +The `audit:live-growth` script now also checks that known index-eligible core tool pages do **not** return a noindex meta tag and appear in the served sitemap, while verifying same-origin self-canonical and unexpected redirects. This is a *post-deployment* check; it intentionally fails when the current production deployment does not yet contain the PR's CTA. Do not run it as a pre-deploy gate against the old production version. + +## Dependency-free regression test (2026-11-05) +`cd site && npm run test:smoke-html` runs the Node built-in test suite for HTML meta robots, canonical extraction, and actual on-domain, same-locale software CTA anchor detection. This was also exercised in an isolated Node.js 22 environment (3 test groups passing). It is **not** a substitute for Next.js/Vitest full tests, real browser QA or deploy verification. + +## Parallel PR integration guard — PR #39 +PR #39 (engagement) also edits `site/src/components/tools/ToolFrame.tsx` and `site/src/lib/analytics/events.ts`, both changed in this PR #38. Do **not** overwrite either file with an entire version copied from one PR while merging the other. + +When integrating: +1. Rebase/merge both branches locally in a scratch branch (not `main`). +2. Preserve in `ToolFrame.tsx` BOTH PR #39's engagement/navigation UI and PR #38's `ToolSoftwareRecommendations` section. +3. Preserve in `events.ts` ALL existing event exports plus PR #39 event changes and PR #38 `trackSoftwareClick`, `trackSoftwareProcess`, `trackSoftwareDownload`. +4. Resolve conflicts, run TypeScript, all tests including `test:smoke-html`, audits and build, and review DOM/keyboard order of CTA vs related/recents. +5. Only after passing checks merge/release through the owner process. Avoid untested sequential merges. + +## Isolated execution evidence +Node.js 22 `node --test` was run on exact reproduced copies of `growth-html-checks.mjs` and its test cases in the working container: 3/3 subtests passed (robots/googlebot; canonical; valid destination/locale/origin). This does **not** validate the complete Next.js application, GitHub PR head in a cloned checkout, nor the deployed website. diff --git a/docs/PR39_VERIFICATION_2026-11-05.md b/docs/PR39_VERIFICATION_2026-11-05.md new file mode 100644 index 00000000..4b59f22d --- /dev/null +++ b/docs/PR39_VERIFICATION_2026-11-05.md @@ -0,0 +1,141 @@ +# PR #39 verification, 2026-11-05 + +## Confirmed +- Current main-to-feature comparison checked through GitHub connector. +- Local isolated `tsc --strict --noEmit` passed for `scenario-comparison.ts`, `scenario-resume.ts`, and `recent-recommendations.ts`. +- Local Node assertion harness passed 29 assertions for the same pure modules. +- Additional Vitest cases for invalid draft timestamps added to the PR. +- Public English percentage page currently renders the original calculator but not the new A/B and resume controls. + +## Still blocking release +- Local `git ls-remote` could not resolve github.com, so a full repository checkout was unavailable. +- Full `npm ci`, project Vitest, app-wide TypeScript checks, Next.js build, browser/mobile QA, and GA4 DebugView were not performed. +- No GitHub Actions, PR merge, VPS deploy, or verified engagement uplift. + +## Next required action +In an owner-accessible checkout run `bash scripts/check-engagement-release.sh`, then test English/Japanese desktop and mobile, blocked storage, expired draft, URL share, keyboard accessibility and consent. Merge and deploy only after all gates pass; measure 7/28-day GA4 engagement. + +This file is a checkpoint, not a passing full-release certificate. + +## Follow-up verification +- Rechecked local Git access: DNS resolution of github.com still fails. +- Re-ran isolated TypeScript strict compilation and 29 independent assertions: passed. This is not the app-wide build. +- Added maxLength=40 to both percentage number inputs to match URL restore and saved draft limits. +- English production page inspected: new compare and resume sections not found in fetched HTML. +- No merge, deployment, or verified engagement uplift. + +## Shared-link restore hardening (2026-11-05) +- Added `scenario-share.ts` and `scenario-share.test.ts` to reject incomplete, malformed, oversized, and non-calculable URL payloads as a whole. Calculator fields are no longer partially restored from an invalid `?s=` payload. +- In the isolated local TypeScript harness, all four pure source modules passed strict checking. The existing 29 assertions and nine new share-state assertions passed (38 total). This is not a full repository Vitest run or Next.js build. +- No GitHub Actions, production deployment, or verified engagement uplift. + +## 2026-11-05 — Related/recent navigation deduplication +- Updated recent recommendation selection to omit slugs already present in the existing related-tools section. This avoids using the same screen real estate for duplicate destinations. +- Added test cases for overlap and validated six cases with standalone Node assertions and TypeScript strict compilation. This is not a full React or Next build. +- HYPD live-browser and mobile Lighthouse audits could not run: the linked service reported the tools unavailable on the current plan. No external mobile performance score or rendered DOM result was obtained. +- Main merge and production deployment remain blocked by full-build/device verification, and no improvement in GA4 engagement has been measured. + +## Security-focused share URL regression (2026-11-05) +- Generic share-state collection excludes password and hidden input types, in addition to file and action-only controls; the policy is centralized in share-input-policy.ts. +- Both generic read and write are capped at 1800 encoded characters; the manual hook is capped at 4096 encoded characters in both directions. Oversized external payloads are rejected before decoding. +- Added share-input-policy.test.ts; independently compiled this exact pure policy source with strict TypeScript and executed 18 standalone Node assertions, all passed. Full project Vitest/build and interactive browser QA remain pending. +- Privacy caveat: other generic input values may still enter a shareable URL. Never describe all tool inputs as local-only or claim privacy audit completion. +- Before release, test older ordinary shared links, password/hidden inputs, history navigation, copy-result-link availability, consent and mobile behavior. No rollout or engagement gain verified. + +## PII-oriented generic sharing guard (2026-11-05) +- Automatic share URL collection now excludes email and telephone input types in addition to passwords/hidden controls. +- It also rejects fields explicitly marked `data-private` or `data-share="off"`, and browser autofill fields such as name, address, postal code, date of birth, account username and payment card tokens (for inputs, selects and textareas). +- Added `share-input-policy.integration.test.ts` alongside policy unit tests. New source verified via GitHub readback. A reconstructed local copy of the pure `share-input-policy.ts` passed strict TypeScript compilation and **36 independent Node assertions**. The new Vitest source was **not** run as a full project test. +- Corrected both sharing hook comments: state placed in `?s=` is **not strictly device-local**; URL history, HTTP requests, copied links and referrers can expose it. Arbitrary remaining free-text values are not guaranteed non-sensitive; a tool-by-tool privacy assessment remains required before release. +- Run the full local release gate, plus manual tests that standard numeric sharing still works, sensitive autofilled values never enter `?s=`, older links open acceptably, and no analytics payload includes entered values. + +## 2026-11-06 security follow-up +- Fixed auto-sharing privacy exclusions to respect data-private or data-share="off" on enclosing field groups, not just on the input itself. +- Updated share-input-policy.integration.test.ts to assert the ancestor exclusion selector. GitHub readback confirmed the change. +- Full local test/build remains unavailable in the present execution environment; this update is not evidence of successful production deployment or GA4 uplift. + +## 2026-11-06: release blocker re-evaluation +- Verified every npm script invoked by `scripts/check-engagement-release.sh` exists in `site/package.json`. This establishes script-name consistency only; it does **not** execute the scripts. +- Six connector-readback consistency checks passed for release script names, ancestor privacy guard, input/textarea/select exclusion, URL length limits, lack of ShareBar polling and navigation-before-ad order. +- Fixed a fragile string-escaping expectation in `share-input-policy.integration.test.ts`. +- The generic URL restore path now ignores decoded field values unless they are strings or booleans, and skips non-integer or out-of-bounds mode button indices. Added source regression checks. +- `git ls-remote` still cannot resolve github.com in the working container, preventing a full checkout and project-level npm ci, Vitest, Next build and browser QA. +- Remains **NO-GO** until a full owner-accessible checkout passes all tests, browser/privacy/analytics checks, and an explicit production deploy. No live engagement improvement measured. + +## 2026-11-06 — URL-driven button activation risk +- Identified that auto-shared URLs previously could trigger clicks on arbitrary tool buttons by index, not just mode controls. +- Limited URL replay and recording of mode-button indices to sibling groups with at least two buttons and an existing active-mode styling marker; disabled and in-form submit controls cannot be replayed. +- Preserved triangle-calculator's non-form mode buttons, which have browser-default submit type but are not form submission controls. +- Added a source regression assertion in `share-input-policy.integration.test.ts`. GitHub readback confirmed code and test source; the project Vitest suite and browser QA **have not run**. +- Remaining test: valid triangle right/sss/sas links, malicious links pointing to action buttons, mode toggles, alternate tools with button-driven modes, mobile UI and sharing result links. +- Release stays NO-GO until the owner-accessible full build and manual QA pass. No performance or revenue lift has been demonstrated. + +## 2026-11-06 — Search-index localization and payload hardening +- `useSearchIndex` now keys fetched data by the requested locale and returns null while an index for the new locale is loading, rather than displaying stale-language titles. +- Extracted malformed search-index response handling into `site/src/lib/search/search-index-payload.ts`. Missing arrays and malformed entries are ignored; valid tool entries are preserved. +- Added unit tests for parsed payloads plus a source-level regression test for locale-scoped display. Reconstructed the exact parser in a standalone local TypeScript harness with a minimal type stub: strict compilation and 10 Node assertions passed. +- This is not a full Next.js app compile or UI-level language-switch test. No GitHub Actions, merge or deployment were performed. +- Full-repo checkout remains blocked by DNS from this container, confirmed again. Run `bash scripts/check-engagement-release.sh` in the owner environment and verify locale switches in browser before release. + +## 2026-11-06 — Search index link validation +- Strengthened the localized search-index parser used by recent-tool navigation: accept only hyphenated ASCII tool slugs (max 100 chars), known tool categories, nonempty localized titles (max 200 chars); drop duplicate slugs. +- Reject malformed path traversal, percent-encoded separators, unknown categories and oversized titles without hiding valid tool suggestions. +- Expanded `search-index-payload.test.ts` for malformed URL targets, duplicate slugs, and title/category validation. +- Reconstructed the exact pure parser in an isolated local TypeScript harness with a minimal compatible SearchItem type stub. `tsc --strict` passed and 15 Node assertions passed. +- **These are isolated checks, not the full repository Vitest/Next build**. No production deployment, mobile QA, or GA4 engagement uplift verified. +- Next gate: full checkout and `bash scripts/check-engagement-release.sh`, then check actual en/ja locale switching and malformed index behavior on mobile. + +## 2026-11-06 — Next.js history state preservation +- Fixed both `useShareableState` and `useAutoShareableState`: share URL updates now call `history.replaceState(history.state, "", url)` rather than replacing the history state with `null`. +- This preserves the client router's pre-existing navigation state when adding/updating `?s=`. Avoid interpreting it as a verified navigation fix until tested in a browser. +- Updated ShareBar.events.test.ts to assert preserved history state and reject the old null-overwrite pattern. +- Read back both GitHub hook files and the test. Both hooks contain the new call and still dispatch share-state-change; neither retains the previous null overwrite. +- Remaining manual QA: edit inputs, use back/forward, switch locale and tools through client navigation, restore shared links after BFCache return and confirm no hydration/router errors. +- Source changes and static readback are verified. **Full Vitest, Next production build, real browser QA, merge and deploy are still pending.** + +## 2026-11-06 — Test fixture correction and isolated verification +- Found misleading `search-index-payload.test.ts` malformed fixture: `slug: "s"` is structurally valid under the new parser; the fixture also lacked a title, masking the invalid-slug assumption. Replaced it with a genuinely unsafe path `../unsafe` plus a valid title so that exclusion is specifically attributable to the slug format. +- Added a separate regression case that explicitly preserves a valid one-character slug; this checks that the validator is not over-restrictive. +- Reproduced the parser logic in an isolated local TypeScript harness with a minimal compatible SearchItem type definition; `tsc --strict` passed and 10 grouped Node assertions passed, including normal, invalid, duplicate and short-slug scenarios. This is NOT a run of project Vitest or Next build. +- Git CLI still cannot resolve github.com in the execution environment; continue requiring owner-accessible `bash scripts/check-engagement-release.sh`, browser QA and reviewed deployment before merging PR #39. + +## 2026-11-06 — GA4 synthetic restore measurement correction +- `ToolInteractionTracker.tsx` now ignores untrusted DOM input/change and click events when tracking `calculate`, `copy_result`, and `preset_click`. Auto shared-URL restore uses programmatic events and `.click()`, so those should not count as actual user engagement. +- Added `ToolInteractionTracker.trust.test.ts` to assert that the user-event boundary and event subscriptions remain in place. +- GitHub readback confirms 5 source consistency checks (trusted input/click guards, listener registration, synthetic restore dispatch and test assertions) passed. **This is static readback, not running Vitest, a browser, or a GA4 debugging session.** +- Browser acceptance: load an auto-shared URL, confirm no `calculate`/preset/copy event before user action; change a field manually and confirm one `calculate`; check consent denied/granted modes; verify no analytics values include user input. +- Release remains NO-GO pending full tests/build and manual owner QA, without GitHub Actions. + +## 2026-11-06 — Client-side route transitions and delayed GA4 events +- `ToolInteractionTracker` now resets one-shot `calculate` / `copy_result` / `preset_click` flags whenever the effect is recreated for a changed tool slug, locale or category. This avoids carrying dedup state from an earlier client-side route. +- Cancelled the delayed 50 ms calculation event on unmount and added a disposed guard. Without this, a queued timer could send an event after a tool was left. +- Added a corresponding `ToolInteractionTracker.trust.test.ts` source regression case and confirmed five source consistency checks by GitHub readback. +- This static verification is NOT a real Vitest run or GA4 DebugView test. Browser acceptance still needs navigate tool A → B without a full reload, operate B and verify its first calculate, then leave before the pending event and verify no late analytics event. +- Release stays draft/NO-GO; no full Next build, review, deployment or observed revenue/engagement improvement. + +## 2026-11-06 — Name/id-based sensitive input protection +- Auto-generated `?s=` state formerly inspected input type, browser autocomplete and opt-out attributes, but did not check developer-assigned field `name` or `id`. Added an optional identifier parameter in `share-input-policy.ts` and wired `name` + `id` for input, textarea and select. +- Filters credential/personal-data hint names including `apiKey`, `privateKey`, `refresh_token`, `creditCard`, `cardNumber`, `billingEmail`, `contactPhone`, and `postalCode`; camelCase is normalized. Regular numeric field identifiers such as `amount`, `valueA`, `angleC` remain eligible. +- Added unit and integration test assertions. GitHub readback verifies the new source and test wiring. Reused the exact source pattern in a JavaScript regex check: **17 identifier cases passed** (12 excluded, 5 allowed). This check is limited to identifier matching, not full Vitest/React/Next behavior. +- Remaining risk: arbitrary free-text can still contain sensitive content even without sensitive field names. Full tool-by-tool privacy review remains a release blocker. Real browser/GA4/consent checks and full `bash scripts/check-engagement-release.sh` are not complete. + +## 2026-11-06 — Oversized share URL stale-state protection +- In both manual and generic sharing hooks, when new input state exceeds the URL size cap, the previous `?s=` is now removed rather than silently left in the URL. This prevents an older result from being offered as if it represented the current calculator input. +- Both paths preserve `window.history.state` and dispatch `toolify:share-state-change` when removing a stale result URL, so the ShareBar "copy result link" control can disappear. +- Updated `ShareBar.events.test.ts` with regression expectations for both hooks. +- GitHub readback verified source/test presence of removal, history preservation and notification, but project-level Vitest, Next build and real browser behavior remain unverified. +- Browser acceptance: generate result URL, exceed size cap, confirm result-link button disappears while normal share stays available; shorten input, confirm result link regenerates; verify back/forward and client route navigation. + +## 2026-11-06 — Private-by-default automatic free-text URL sharing +- Significant privacy model change: `useAutoShareableState` no longer serializes `text`, `search`, `url` or `textarea` fields into `?s=` automatically. A reviewed tool must explicitly mark such a field `data-share="on"` to permit its inclusion. +- Numeric fields, selects, checkboxes, dates and other non-free-text controls retain existing eligibility after private/type/autocomplete/name/id exclusions. Explicit privacy exclusions still override opt-in. +- Added pure policy and hook integration regression assertions and confirmed **7 GitHub source consistency checks** for policy, opt-in wiring and tests. These checks DO NOT constitute running Vitest, TypeScript project typecheck or Next.js build. +- Trade-off: text-processing tools may no longer preserve their entire text input when copying generic result links. This intentional reduction needs UX acceptance and tool-by-tool review before enabling any opt-in. Previously generated links can still be opened, but excluded text values are not restored by the generic hook. +- The separate manually registered `useShareableState` hook is not automatically covered by this change; its tool-specific state must be reviewed for sensitive content. +- Release remains NO-GO pending full owner-accessible build/test gate, text tool privacy review, mobile browser QA, analytics consent and actual deployment verification. + +## 2026-11-06 — Untrusted shared URL value type and option checks +- Generic URL restore now validates decoded values against the target DOM input type before calling native setters or clicking checkbox/radio controls. +- Only boolean values may restore checkboxes; radio controls require string values that exactly match the target radio's own value; select controls require an option present in the current select. Numeric/range fields require a finite numeric string or an empty string. String values are limited to 1800 characters. +- Added unit and integration regression tests for the policy and hook wiring. GitHub source readback confirms **6 static consistency checks** (policy export, use, select/radio validation, tests). The Vitest tests have been added but **have not run**. +- Remaining concerns: browser behavior when options change dynamically, old share link compatibility, and manually registered `useShareableState` consumers. Full owner checkout QA, typecheck/build, consent verification and desktop/mobile tests remain release blockers. diff --git a/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md new file mode 100644 index 00000000..b6792082 --- /dev/null +++ b/docs/PR40_INTEGRATION_VERIFICATION_2026-11-06.md @@ -0,0 +1,120 @@ +# PR #40 — Toolify growth + engagement integration gate + +Date: 2026-11-06 JST. Integration from PR #39 head plus PR #38 changes. Original PRs and `main` are unchanged. + +## Integration proof (GitHub source readback) +- `site/src/components/tools/ToolFrame.tsx`: both `ContinueWithRecent` and `ToolSoftwareRecommendations` imported and rendered. +- `site/src/lib/analytics/events.ts`: `trackRecentToolClick`, `trackScenarioAction`, `trackSoftwareClick`, `trackSoftwareProcess`, `trackSoftwareDownload` exports retained. +- `site/package.json`: `test:smoke-html`, `audit:live-growth` retained. +- GitHub compare against `main`: 91 commits ahead, zero behind, 35 changed files at PR #40 creation. This is **not** evidence of conflict-free runtime behavior. +- Separate locally reconstructed copy of the PR #38 HTML helper/test: `node --test` = 3 passed, 0 failed under Node 22.16.0. This does not cover all of PR #40. + +## Mandatory local gate — no GitHub Actions +```bash +git fetch origin +git checkout integration/toolify-growth-engagement-20261106 +cd site +npm ci +npm run test:smoke-html +npm run typecheck +npm run test +npm run validate +npm run audit:seo +npm run audit:i18n +npm run audit:index-policy -- --json +npm run build +``` +Stop on any failure. Make necessary fixes **on the integration branch**, then rerun the full suite. + +## Functional and privacy checks +- Mobile/desktop en and ja pages: recent-tool controls and software CTA coexist without duplicated destinations or hidden primary tool. +- Percentage calculator: 2-scenario comparison, optional save/resume/clear, share/restore, malformed URL and expired drafts. +- ShareBar: no password/hidden value exposed in generic sharing, length cap enforced, event subscriptions behave correctly after back/forward. +- Software: CSV Cleaner Pro, JSON Studio, Text Batch Studio process and download correctly; invalid input and max-size errors handled without data upload. +- GA4: consent-aware events for recent click, scenario actions, software click/process/download. Ensure no user inputs, file contents or names are transmitted. +- Accessibility: keyboard/focus and screenreader link labels in both locales. + +## Deployment gate +Do **not** merge PR #38 and #39 separately after PR #40 merges. Review the combined PR and merge **only PR #40** once all checks pass, then close or supersede the original PRs as appropriate. Owner controls production deployment. +```bash +cd site +SITE_URL=https://toolify365.com npm run audit:live-growth +``` +Run this only after production deployment (the old site has no new CTA and correctly fails). Independently verify live GSC/Bing indexing; do not mass-index 17 locales without 28-day query evidence. + +## Current release result +**NO-GO**: full checkout, TypeScript/Vitest/Next build, cross-feature browser checks and production deploy not executed in this environment. Direct clone to private GitHub repository fails DNS resolution here. No search traffic or revenue improvement verified. + +## 2026-11-06 incremental integration QA +- The merged `ToolFrame` now presents contextual software CTAs **after** related/recent navigation but **before** advertising blocks. The corresponding `ToolFrame.order.test.ts` asserts this order. +- Actual Node 22.16.0 isolated execution of exact HTML helpers and tests from the integration branch: **3 passed / 0 failed**. The full Next/Vitest suite remains unrun because `git ls-remote` still cannot resolve `github.com` from the local container. +- PR #39 continued receiving changes while this integration was being prepared. Its updated `useAutoShareableState.ts` and sharing regression test have been copied into this branch; confirm fresh tree state again just before final merge because upstream may keep changing. +- Source-readback verified `ToolFrame` positions: workspace, recent navigation, software CTA, and ad blocks in that order; five analytics exports intact. This does not establish working browser behavior. +- Do not merge while PR #39 is actively evolving. Freeze both PR heads, recheck latest changed files, and run complete tests against the final integration tree. + +## One-command combined local gate (2026-11-06) +The existing root `scripts/check-engagement-release.sh` now runs both PR #39's full checks and PR #38's dependency-free `test:smoke-html` before typecheck/Vitest/audits/build. On an owner-accessible checkout run `bash scripts/check-engagement-release.sh` from repository root; it uses `npm ci` and does not run GitHub Actions, merge or deploy. The shell script itself has not been run in this environment (private repo cannot be cloned). A passing run still requires real browser/GA4 checks before merging and owner-managed deployment. + +## 2026-11-06 morning sync (search safety + combined regression gate) +- PR #39 added `search-index-payload.ts` and tests plus `useSearchIndex` validation. These were ported to PR #40, then the latest stricter payload validation (known categories, safe slug pattern, title limits, deduplication) and expanded tests were resynced. +- Compared against category configuration: parser's eight categories match `ToolCategory`'s eight configured categories. +- Added `site/scripts/growth-integration.test.mjs` with three dependency-free checks: workspace/navigation/CTA/ad order, five GA4 exports and consumers, and catalog-gated recommendation wiring. +- Registered `npm run test:integration` and added it to `scripts/check-engagement-release.sh` along with `test:smoke-html`. +- GitHub connector readback verified all three tests are present, the new npm command is wired, the UI order is workspace → recent → software CTA → ad, and the five tracking functions are retained. **This is source readback, not a completed run of the full integration gate.** +- Direct clone still fails DNS from available execution environment. Avoid merging PR #40 until full npm install, Vitest, typecheck, browser/device checks and build run in an authorized checkout. + +## 2026-11-06 fresh PR39 parity and rendered HTML regression +- Compared latest PR #39 changes to integration PR #40: 7 highlighted files (sharing policy/hook, search-index validation/hook/tests and verification record) were fetched by the GitHub connector and all seven file contents matched exactly. +- Repeated local direct git clone probe: `Could not resolve host: github.com`; full repository checkout still not available in execution container. +- Ran Node.js 22.16.0 with a local copy of the exact growth HTML helper/test modules: **3 test groups passed, zero failed**. +- Identified a misleading success case: serialized HTML text inside hydration `'), false); +}); +test('canonical regardless of attribute order', () => { + assert.equal(canonicalHref(''), 'https://toolify365.com/en/tools/foo'); + assert.equal(canonicalHref(''), 'https://toolify365.com/en/tools/foo'); + assert.equal(canonicalHref(''), null); + assert.equal(canonicalHref(''), '/right'); +}); +test('CTA must be an anchor, not an inert script string', () => { + assert.equal(hasSoftwareAnchor('Open', 'json-studio', 'en'), true); + assert.equal(hasSoftwareAnchor('', 'json-studio', 'en'), false); + assert.equal(hasSoftwareAnchor('', 'json-studio', 'en'), false); + assert.equal(hasSoftwareAnchor('', 'json-studio', 'en'), false); + assert.equal(hasSoftwareAnchor('', 'json-studio', 'en'), false); + assert.equal(hasSoftwareAnchor('Open', 'json-studio', 'en'), false); + assert.equal(hasSoftwareAnchor('Open', 'json-studio', 'ja'), false); + assert.equal(hasSoftwareAnchor('Open', 'json-studio', 'en'), false); + assert.equal(hasSoftwareAnchor('Open', 'json-studio', 'en', 'https://staging.example'), true); +}); diff --git a/site/scripts/growth-integration.test.mjs b/site/scripts/growth-integration.test.mjs new file mode 100644 index 00000000..094ab1ee --- /dev/null +++ b/site/scripts/growth-integration.test.mjs @@ -0,0 +1,73 @@ +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { readFileSync } from 'node:fs'; + +const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8'); +const frame = read('../src/components/tools/ToolFrame.tsx'); +const events = read('../src/lib/analytics/events.ts'); +const workbench = read('../src/components/software/SoftwareWorkbench.tsx'); +const recent = read('../src/components/tools/ContinueWithRecent.tsx'); +const recs = read('../src/lib/software/recommendations.ts'); + +test('growth and engagement navigation coexist ahead of advertising', () => { + for (const name of ['ToolInteractionTracker', 'ContinueWithRecent', 'ToolSoftwareRecommendations', 'AdBelowResult']) { + assert.ok(frame.includes('<' + name), 'missing ' + name); + } + const order = [' frame.indexOf(s)); + assert.ok(order.every(i => i >= 0)); + assert.ok(order.every((v, i) => i === 0 || order[i - 1] < v), 'navigation must precede ads'); +}); + +test('all five integration analytics functions coexist', () => { + for (const name of ['trackRecentToolClick', 'trackScenarioAction', 'trackSoftwareClick', 'trackSoftwareProcess', 'trackSoftwareDownload']) { + assert.match(events, new RegExp('export function ' + name + '\\(')); + } + assert.ok(workbench.includes('trackSoftwareProcess(')); + assert.ok(workbench.includes('trackSoftwareDownload(')); + assert.ok(recent.includes('trackRecentToolClick(')); +}); + +test('catalog-gated software CTA mappings are present', () => { + assert.ok(recs.includes('AVAILABLE_SOFTWARE')); + assert.ok(recs.includes('availableRecommendations')); + assert.ok(frame.includes('slug={meta.slug} locale={locale}')); +}); + +test('synthetic restoration events cannot inflate active tool engagement', () => { + const tracker = read('../src/components/tools/ToolInteractionTracker.tsx'); + const restore = read('../src/lib/hooks/useAutoShareableState.ts'); + assert.ok(tracker.includes('if (!e.isTrusted || sent.current.calculate) return')); + assert.ok(tracker.includes('if (!e.isTrusted) return')); + assert.ok(tracker.includes('sent.current = { calculate: false, copy: false, preset: false }')); + assert.ok(tracker.includes('if (disposed) return')); + assert.ok(tracker.includes('window.clearTimeout(calculateDelay)')); + assert.ok(tracker.includes('}, [slug, locale, category])')); + assert.ok(restore.includes('dispatchEvent(new Event("input", { bubbles: true }))')); +}); + +test('automatic share URLs exclude sensitive named and autofilled fields', () => { + const policy = read('../src/lib/hooks/share-input-policy.ts'); + const hook = read('../src/lib/hooks/useAutoShareableState.ts'); + assert.ok(policy.includes('SENSITIVE_IDENTIFIER')); + assert.ok(policy.includes('SENSITIVE_AUTOCOMPLETE')); + assert.ok(policy.includes('SENSITIVE_IDENTIFIER.test(normalizedIdentifier)')); + assert.ok(policy.includes('explicitTextOptIn = false')); + assert.ok(policy.includes('freeTextType && !explicitTextOptIn')); + assert.ok(hook.includes('const identifier =')); + assert.ok(hook.includes('const explicitTextOptIn = el.getAttribute("data-share") === "on"')); + assert.ok(hook.includes('isShareableField(el.type, el.autocomplete, privateField, identifier, explicitTextOptIn)')); + assert.ok(hook.includes('isShareableField("textarea", el.autocomplete, privateField, identifier, explicitTextOptIn)')); + assert.ok(hook.includes('isShareableField("select", el.getAttribute("autocomplete") ?? "", privateField, identifier, explicitTextOptIn)')); +}); + +test('shared URL restoration validates untrusted input and clears stale oversized state', () => { + const policy = read('../src/lib/hooks/share-input-policy.ts'); + const hook = read('../src/lib/hooks/useAutoShareableState.ts'); + assert.ok(policy.includes('export function isValidRestoredFieldValue(')); + assert.ok(policy.includes('Number.isFinite(Number(value))')); + assert.ok(hook.includes('if (!isValidRestoredFieldValue(kind, v)) continue')); + assert.ok(hook.includes('Array.from(el.options).some((option) => option.value === v)')); + assert.ok(hook.includes('el.type === "radio" && el.value !== v')); + assert.ok(hook.includes('stale.searchParams.delete("s")')); + assert.ok(hook.includes('window.dispatchEvent(new Event("toolify:share-state-change"))')); +}); diff --git a/site/src/components/software/SoftwareWorkbench.tsx b/site/src/components/software/SoftwareWorkbench.tsx index 546d0ce4..c3a3760c 100644 --- a/site/src/components/software/SoftwareWorkbench.tsx +++ b/site/src/components/software/SoftwareWorkbench.tsx @@ -1,5 +1,6 @@ "use client"; import { useState } from "react"; +import { trackSoftwareProcess, trackSoftwareDownload } from "@/lib/analytics/events"; import { FREE_SOFTWARE_LIMITS, isWithinSoftwareLimits } from "@/lib/software/limits"; import { cleanCsv, cleanTextLines, formatJson, prepareSoftwareDownload } from "@/lib/software/processors"; @@ -13,8 +14,13 @@ export function SoftwareWorkbench({ slug, labels }: {slug: ActiveSlug; labels: { setError(""); setOutput(null); try { if (!isWithinSoftwareLimits(slug, input)) throw new Error(labels.limit); - setOutput(slug === "csv-cleaner-pro" ? cleanCsv(input) : slug === "json-studio" ? formatJson(input, minify) : cleanTextLines(input)); - } catch (e) { setError(e instanceof Error && e.message === labels.limit ? labels.limit : labels.error); } + const result = slug === "csv-cleaner-pro" ? cleanCsv(input) : slug === "json-studio" ? formatJson(input, minify) : cleanTextLines(input); + setOutput(result); + trackSoftwareProcess({ software: slug, success: true }); + } catch (e) { + trackSoftwareProcess({ software: slug, success: false }); + setError(e instanceof Error && e.message === labels.limit ? labels.limit : labels.error); + } }; const loadFile = async (file: File | undefined) => { if (!file) return; @@ -39,6 +45,7 @@ export function SoftwareWorkbench({ slug, labels }: {slug: ActiveSlug; labels: { const ext = slug === "csv-cleaner-pro" ? "csv" : slug === "json-studio" ? "json" : "txt"; const url = URL.createObjectURL(new Blob([prepareSoftwareDownload(output, ext)], {type: mime + ";charset=utf-8"})); const a = document.createElement("a"); a.href = url; a.download = "toolify365-output." + ext; document.body.appendChild(a); a.click(); a.remove(); + trackSoftwareDownload({ software: slug }); setTimeout(() => URL.revokeObjectURL(url), 1000); }; return
diff --git a/site/src/components/tools/ContinueWithRecent.tsx b/site/src/components/tools/ContinueWithRecent.tsx new file mode 100644 index 00000000..112a18c1 --- /dev/null +++ b/site/src/components/tools/ContinueWithRecent.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useLocale, useTranslations } from "next-intl"; +import { useRecent } from "@/lib/recent"; +import { useSearchIndex } from "@/lib/search/useSearchIndex"; +import { Link } from "@/lib/i18n/navigation"; +import { trackRecentToolClick } from "@/lib/analytics/events"; +import { selectRecentRecommendations } from "@/lib/tools/recent-recommendations"; + +/** + * Offers a contextual return path after completing a tool. + * This is intentionally shown only to returning visitors and does not store inputs. + * Reuses the existing recent-slug store and lazily fetched localized search index. + */ +export function ContinueWithRecent({ currentSlug, category, relatedSlugs = [] }: { currentSlug: string; category: string; relatedSlugs?: string[] }) { + const locale = useLocale(); + const t = useTranslations("tool"); + const recent = useRecent(); + const hasHistory = recent.some((slug) => slug !== currentSlug); + const index = useSearchIndex(locale, hasHistory); + + if (!index || !hasHistory) return null; + const bySlug = new Map(index.map((item) => [item.slug, item])); + const alternatives = selectRecentRecommendations(recent, currentSlug, new Set(bySlug.keys()), 3, new Set(relatedSlugs)); + const items = alternatives.map((slug) => bySlug.get(slug)).filter((item) => item !== undefined); + if (items.length === 0) return null; + + return ( +
+

🕘 {t("recent")}

+
    + {items.map((item) => ( +
  • + trackRecentToolClick({ tool: currentSlug, locale, category, destination: item.slug })} + className="flex min-h-11 items-center rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-brand-700 hover:border-brand-400 hover:bg-brand-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-brand-400 dark:hover:bg-slate-800" + > + {item.title} + + +
  • + ))} +
+
+ ); +} diff --git a/site/src/components/tools/ShareBar.events.test.ts b/site/src/components/tools/ShareBar.events.test.ts new file mode 100644 index 00000000..75f5e0d1 --- /dev/null +++ b/site/src/components/tools/ShareBar.events.test.ts @@ -0,0 +1,43 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const get = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); + +describe("share URL controls", () => { + it("uses change notifications rather than permanent URL polling", () => { + const shareBar = get("./ShareBar.tsx"); + expect(shareBar).toContain('window.addEventListener("toolify:share-state-change", sync)'); + expect(shareBar).toContain('window.removeEventListener("toolify:share-state-change", sync)'); + expect(shareBar).toContain('window.addEventListener("popstate", sync)'); + expect(shareBar).toContain('window.addEventListener("pageshow", sync)'); + expect(shareBar).not.toContain("setInterval(sync"); + }); + + it("clears previously shared result links when new inputs become too large", () => { + for (const file of [ + "../../lib/hooks/useShareableState.ts", + "../../lib/hooks/useAutoShareableState.ts", + ]) { + const source = get(file); + expect(source).toContain('stale.searchParams.has("s")'); + expect(source).toContain('stale.searchParams.delete("s")'); + expect(source).toContain('window.history.replaceState(window.history.state, "", stale.toString())'); + expect(source).toContain('window.dispatchEvent(new Event("toolify:share-state-change"))'); + } + }); + + it("notifies ShareBar after both URL writer implementations update state", () => { + for (const file of [ + "../../lib/hooks/useShareableState.ts", + "../../lib/hooks/useAutoShareableState.ts", + ]) { + const source = get(file); + expect(source).toContain('window.history.replaceState(window.history.state, "", url.toString())'); + expect(source).not.toContain('window.history.replaceState(null, "", url.toString())'); + const replaceAt = source.indexOf("window.history.replaceState("); + const notifyAt = source.indexOf('window.dispatchEvent(new Event("toolify:share-state-change"))'); + expect(replaceAt).toBeGreaterThan(0); + expect(notifyAt).toBeGreaterThan(replaceAt); + } + }); +}); diff --git a/site/src/components/tools/ShareBar.tsx b/site/src/components/tools/ShareBar.tsx index 837e186c..ef26e10f 100644 --- a/site/src/components/tools/ShareBar.tsx +++ b/site/src/components/tools/ShareBar.tsx @@ -93,7 +93,7 @@ export function ShareBar({ url, embedUrl, title, slug }: Props) { const [showEmbed, setShowEmbed] = useState(false); // useShareableState を使うツールでは入力のたびに ?s= が history.replaceState で付く。 - // replaceState は再レンダーを起こさないため、pointer/keyboard 操作のたびに読み直す。 + // replaceState は再レンダーを起こさないため、書き換え通知と履歴イベントで同期する。 // ?s= が無いツール(大半)ではボタン自体を出さない。 useEffect(() => { const sync = () => { @@ -104,8 +104,16 @@ export function ShareBar({ url, embedUrl, title, slug }: Props) { } }; sync(); - const id = window.setInterval(sync, 1000); - return () => window.clearInterval(id); + // Both share hooks dispatch this event after their history.replaceState. + // Also resync on browser history navigation and BFCache return. + window.addEventListener("toolify:share-state-change", sync); + window.addEventListener("popstate", sync); + window.addEventListener("pageshow", sync); + return () => { + window.removeEventListener("toolify:share-state-change", sync); + window.removeEventListener("popstate", sync); + window.removeEventListener("pageshow", sync); + }; }, []); const enc = encodeURIComponent; diff --git a/site/src/components/tools/ToolFrame.order.test.ts b/site/src/components/tools/ToolFrame.order.test.ts new file mode 100644 index 00000000..9738908c --- /dev/null +++ b/site/src/components/tools/ToolFrame.order.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(new URL("./ToolFrame.tsx", import.meta.url), "utf8"); + +describe("tool result follow-up placement", () => { + it("places related and recent tools after the workspace but ahead of ads", () => { + const workspace = source.indexOf(" 0"); + const recent = source.indexOf(" i >= 0)).toBe(true); + expect(workspace).toBeLessThan(related); + expect(related).toBeLessThan(recent); + expect(recent).toBeLessThan(software); + expect(software).toBeLessThan(resultAd); + expect(resultAd).toBeLessThan(bannerAd); + }); + it("retains the existing ad inventory", () => { + expect(source).toContain(" - - - - {/* 関連ツール導線 — ツール結果の直後(注意が集中する位置)に移動。 従来は記事/FAQ/出典の後の最下部で不可視だった。回遊(PV/session)増=AdSense表示増。 コンパクトなチップ列で本文の邪魔をしない。 */} @@ -95,6 +93,17 @@ export function ToolFrame({ meta, title, description, related, children, article )} + {/* Return-visit path: show up to three previously used tools without storing inputs. */} + r.slug)} /> + + {/* Intent-matched free software follows related/recent navigation, before monetization blocks. */} + + + {/* Keep task-follow-up navigation immediately after results; retain monetization units below it. */} + + + +

{t("tool.howItWorks")}

{article} diff --git a/site/src/components/tools/ToolInteractionTracker.trust.test.ts b/site/src/components/tools/ToolInteractionTracker.trust.test.ts new file mode 100644 index 00000000..d25e5efe --- /dev/null +++ b/site/src/components/tools/ToolInteractionTracker.trust.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(new URL("./ToolInteractionTracker.tsx", import.meta.url), "utf8"); +const restore = readFileSync(new URL("../../lib/hooks/useAutoShareableState.ts", import.meta.url), "utf8"); + +describe("user-initiated tool engagement telemetry", () => { + it("does not count synthetic input/change restoration events as calculate", () => { + expect(source).toContain("function onInput(e: Event)"); + expect(source).toContain("if (!e.isTrusted || sent.current.calculate) return"); + expect(source).toContain('el.addEventListener("input", onInput)'); + expect(source).toContain('el.addEventListener("change", onInput)'); + expect(restore).toContain('el.dispatchEvent(new Event("input", { bubbles: true }))'); + }); + + it("resets event dedup for client navigation and cancels delayed telemetry on unmount", () => { + expect(source).toContain("sent.current = { calculate: false, copy: false, preset: false }"); + expect(source).toContain("let calculateDelay: number | undefined"); + expect(source).toContain("let disposed = false"); + expect(source).toContain("if (disposed) return"); + expect(source).toContain("disposed = true"); + expect(source).toContain("window.clearTimeout(calculateDelay)"); + expect(source).toContain("}, [slug, locale, category])"); + }); + + it("does not count programmatic mode replay as preset or copy actions", () => { + expect(source).toContain("function onClick(e: Event)"); + expect(source).toContain("if (!e.isTrusted) return"); + expect(restore).toContain("btn.click()"); + }); +}); diff --git a/site/src/components/tools/ToolInteractionTracker.tsx b/site/src/components/tools/ToolInteractionTracker.tsx index 3a7c6d5c..72d37351 100644 --- a/site/src/components/tools/ToolInteractionTracker.tsx +++ b/site/src/components/tools/ToolInteractionTracker.tsx @@ -47,6 +47,8 @@ export function ToolInteractionTracker({ useEffect(() => { const el = ref.current; if (!el) return; + // A client-side route can reuse this mounted component for a different tool. + sent.current = { calculate: false, copy: false, preset: false }; // data-calc-label を持つツール(2026-10-02: pressure-converter / workdays-calculator、10-03: date-calculator)は // 「何を計算したか」(単位ペア・祝日プリセット等)を calculate の label に載せる。 @@ -54,8 +56,11 @@ export function ToolInteractionTracker({ // (LABEL_SETTLE_MS) またはタブを離れる時に1回だけ送る。新しいイベント名は作らない // = 全ツールで calculate は従来どおり1着地1回(重複発火しない)。 let settle: number | undefined; + let calculateDelay: number | undefined; + let disposed = false; function sendWithLabel() { + if (disposed) return; if (sent.current.calculate) return; sent.current.calculate = true; if (settle !== undefined) window.clearTimeout(settle); @@ -72,8 +77,9 @@ export function ToolInteractionTracker({ }); } - function onInput() { - if (sent.current.calculate) return; + function onInput(e: Event) { + // URL restore uses synthetic input/change events, not real user actions. + if (!e.isTrusted || sent.current.calculate) return; const root = el as HTMLElement; if (root.querySelector("[data-calc-label]")) { if (settle !== undefined) window.clearTimeout(settle); @@ -82,7 +88,8 @@ export function ToolInteractionTracker({ } sent.current.calculate = true; // 結果描画はツール側の再レンダー後なので、1 フレーム待ってから DOM を見る。 - window.setTimeout(() => { + calculateDelay = window.setTimeout(() => { + if (disposed) return; trackCalculate({ tool: slug, locale, @@ -98,6 +105,8 @@ export function ToolInteractionTracker({ } function onClick(e: Event) { + // Programmatic mode replay must not inflate GA4 user-action events. + if (!e.isTrusted) return; const target = e.target as HTMLElement | null; const btn = target?.closest?.("button"); if (!btn) return; @@ -127,8 +136,10 @@ export function ToolInteractionTracker({ el.addEventListener("click", onClick); document.addEventListener("visibilitychange", onHide); return () => { + disposed = true; document.removeEventListener("visibilitychange", onHide); if (settle !== undefined) window.clearTimeout(settle); + if (calculateDelay !== undefined) window.clearTimeout(calculateDelay); el.removeEventListener("input", onInput); el.removeEventListener("change", onInput); el.removeEventListener("click", onClick); diff --git a/site/src/components/tools/ToolSoftwareRecommendations.test.ts b/site/src/components/tools/ToolSoftwareRecommendations.test.ts new file mode 100644 index 00000000..6fdec5f0 --- /dev/null +++ b/site/src/components/tools/ToolSoftwareRecommendations.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { SOFTWARE_RECOMMENDATIONS, availableRecommendations } from "@/lib/software/recommendations"; +import { AVAILABLE_SOFTWARE } from "@/lib/software/catalog"; +import { INDEXED_SLUGS, getTool } from "@/lib/tools/registry"; + +describe("tool -> available software recommendations", () => { + it("points only to products currently available", () => { + const available = new Set(AVAILABLE_SOFTWARE.map((product) => product.slug)); + for (const [tool, recommendations] of Object.entries(SOFTWARE_RECOMMENDATIONS)) { + expect(getTool(tool), `Unknown tool: ${tool}`).toBeDefined(); + expect(recommendations.length, tool).toBeGreaterThan(0); + expect(new Set(recommendations).size, tool).toBe(recommendations.length); + for (const slug of recommendations) expect(available.has(slug), `${tool} -> ${slug}`).toBe(true); + } + }); + + it("returns an empty list for tools without recommendations", () => { + expect(availableRecommendations("nonexistent-tool")).toEqual([]); + }); + + it("includes at least one already-indexable tool page", () => { + const indexedMatches = Object.keys(SOFTWARE_RECOMMENDATIONS).filter((slug) => INDEXED_SLUGS.has(slug)); + expect(indexedMatches).toContain("reverse-text-generator"); + expect(indexedMatches).toContain("character-frequency"); + }); + + it("routes CSV and JSON use cases to related free products", () => { + expect(SOFTWARE_RECOMMENDATIONS["csv-json-converter"]).toContain("csv-cleaner-pro"); + expect(SOFTWARE_RECOMMENDATIONS["json-formatter"]).toContain("json-studio"); + expect(SOFTWARE_RECOMMENDATIONS["duplicate-line-remover"]).toContain("text-batch-studio"); + }); +}); diff --git a/site/src/components/tools/ToolSoftwareRecommendations.tsx b/site/src/components/tools/ToolSoftwareRecommendations.tsx new file mode 100644 index 00000000..30a089c2 --- /dev/null +++ b/site/src/components/tools/ToolSoftwareRecommendations.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Link } from "@/lib/i18n/navigation"; +import { trackSoftwareClick } from "@/lib/analytics/events"; +import { availableRecommendations } from "@/lib/software/recommendations"; +import { softwareCopy } from "@/lib/software/copy"; + +export function ToolSoftwareRecommendations({ slug, locale }: { slug: string; locale: string }) { + const products = availableRecommendations(slug); + if (!products.length) return null; + const copy = softwareCopy(locale); + return ( +
+

{copy.title}

+

{copy.privacy}

+
    + {products.map((product) => ( +
  • + trackSoftwareClick({ tool: slug, locale, destination: product.slug })} + className="inline-flex min-h-11 items-center rounded-lg border border-brand-600 px-3 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:text-brand-300 dark:hover:bg-slate-800" + > + {copy.open}: {product.name} + +
  • + ))} +
+
+ ); +} diff --git a/site/src/lib/analytics/events.ts b/site/src/lib/analytics/events.ts index 93adc29d..8250719d 100644 --- a/site/src/lib/analytics/events.ts +++ b/site/src/lib/analytics/events.ts @@ -143,3 +143,45 @@ export function trackAffiliateClick(p: { ...(p.locale ? { locale: p.locale } : {}), }); } + +/** Returning-visitor navigation. No inputs, query content, or URL values are tracked. */ +export function trackRecentToolClick(p: ToolEventParams & { destination: string }): void { + emit("recent_tool_click", { + ...toParams(p), + destination_tool: p.destination, + }); +} + +/** Privacy-safe interaction measurement for percentage scenario features. No inputs or results. */ +export function trackScenarioAction(p: { + tool: "percentage-calculator"; + locale?: string; + action: "compare_save" | "compare_restore" | "compare_clear" | "draft_save" | "draft_restore" | "draft_clear"; +}): void { + emit("scenario_action", { + tool_slug: p.tool, + ...(p.locale ? { locale: p.locale } : {}), + action: p.action, + }); +} + +/** + * Transition from a free tool to currently available in-browser software. + * destination is a finite catalog slug; do not send user-generated content. + */ +export function trackSoftwareClick(p: ToolEventParams & { destination: string }): void { + emit("software_click", { + ...toParams(p), + destination: p.destination, + }); +} + +/** In-browser software usage: only catalog slug and success state, never user input. */ +export function trackSoftwareProcess(p: { software: string; success: boolean }): void { + emit("software_process", { software_slug: p.software, success: p.success }); +} + +/** In-browser generated file download; never send file names or contents. */ +export function trackSoftwareDownload(p: { software: string }): void { + emit("software_download", { software_slug: p.software }); +} diff --git a/site/src/lib/hooks/share-input-policy.integration.test.ts b/site/src/lib/hooks/share-input-policy.integration.test.ts new file mode 100644 index 00000000..6c29eeef --- /dev/null +++ b/site/src/lib/hooks/share-input-policy.integration.test.ts @@ -0,0 +1,43 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(new URL("./useAutoShareableState.ts", import.meta.url), "utf8"); +const manual = readFileSync(new URL("./useShareableState.ts", import.meta.url), "utf8"); + +describe("share policy integration", () => { + it("uses the PII-aware policy for inputs, textareas and selects", () => { + expect(source).toContain("isShareableField(el.type, el.autocomplete, privateField, identifier, explicitTextOptIn)"); + expect(source).toContain('isShareableField("textarea", el.autocomplete, privateField, identifier, explicitTextOptIn)'); + expect(source).toContain('isShareableField("select", el.getAttribute("autocomplete") ?? "", privateField, identifier, explicitTextOptIn)'); + expect(source).toContain('const privateField = !!el.closest('); + expect(source).toContain('data-share='); + expect(source).toContain('const identifier = `${el.getAttribute("name") ?? ""} ${el.id}`'); + expect(source).toContain('const explicitTextOptIn = el.getAttribute("data-share") === "on"'); + }); + it("ignores malformed decoded fields and out-of-range mode buttons", () => { + expect(source).toContain('typeof v !== "string" && typeof v !== "boolean"'); + expect(source).toContain('Number.isSafeInteger(i)'); + expect(source).toContain('i >= all.length'); + }); + + it("rejects invalid restored field types and unavailable option values", () => { + expect(source).toContain("isValidRestoredFieldValue(kind, v)"); + expect(source).toContain('option.value === v'); + expect(source).toContain('el.type === "radio" && el.value !== v'); + }); + + it("replays URL button indices only within a mode-selector group", () => { + expect(source).toContain("function isModeButton(btn: HTMLButtonElement)"); + expect(source).toContain("siblings.length >= 2"); + expect(source).toContain("btn.closest(\"form\")"); + expect(source).toContain("btn && isModeButton(btn)"); + expect(source).toContain("isModeButton(b) && b.className.includes(ACTIVE_CLASS)"); + }); + + it("retains payload size limits in both sharing hooks", () => { + expect(source).toContain("isSharePayloadWithinLimit(raw, MAX_ENCODED)"); + expect(source).toContain("isSharePayloadWithinLimit(encoded, MAX_ENCODED)"); + expect(manual).toContain("raw.length > MAX_SHARED_STATE_LENGTH"); + expect(manual).toContain("encoded.length > MAX_SHARED_STATE_LENGTH"); + }); +}); diff --git a/site/src/lib/hooks/share-input-policy.test.ts b/site/src/lib/hooks/share-input-policy.test.ts new file mode 100644 index 00000000..d64cdf6d --- /dev/null +++ b/site/src/lib/hooks/share-input-policy.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { isShareableInputType, isShareableField, isSharePayloadWithinLimit, isValidRestoredFieldValue } from "./share-input-policy"; + +describe("generic share URL input policy", () => { + it("never serializes credentials or invisible inputs", () => { + for (const type of ["password", "hidden", "file", "submit", "button", "reset", "image", "PASSWORD", "email", "tel"]) { + expect(isShareableInputType(type)).toBe(false); + } + }); + it("keeps ordinary useful calculation fields", () => { + for (const type of ["text", "number", "checkbox", "radio", "date", "range"]) { + expect(isShareableInputType(type)).toBe(true); + } + }); + it("excludes sensitive autofill fields and explicit private annotations", () => { + for (const autocomplete of [ + "email", "section-payment cc-number", "name", "given-name", + "family-name", "street-address", "postal-code", "tel-national", + "username", "one-time-code", "bday-year", + ]) { + expect(isShareableField("text", autocomplete)).toBe(false); + } + expect(isShareableField("text", "off", true)).toBe(false); + expect(isShareableField("number", "off", true)).toBe(false); + expect(isShareableField("text", "off")).toBe(false); + expect(isShareableField("number")).toBe(true); + expect(isShareableField("select", "country")).toBe(false); + expect(isShareableField("textarea", "street-address")).toBe(false); + }); + + it("requires reviewed explicit opt-in for free-form text while retaining numeric fields", () => { + for (const type of ["text", "textarea", "search", "url"]) { + expect(isShareableField(type)).toBe(false); + expect(isShareableField(type, "off", false, "notes", true)).toBe(true); + } + expect(isShareableField("text", "email", false, "notes", true)).toBe(false); + expect(isShareableField("text", "off", true, "notes", true)).toBe(false); + expect(isShareableField("number")).toBe(true); + expect(isShareableField("checkbox")).toBe(true); + }); + + it("excludes sensitive name/id identifiers even without autocomplete", () => { + for (const identifier of [ + "apiKey", "user-password", "refresh_token", "creditCard", + "cardNumber", "cvv", "billingEmail", "contactPhone", "postalCode", + "secret", "privateKey", "socialSecurity", + ]) { + expect(isShareableField("text", "off", false, identifier, true)).toBe(false); + } + for (const identifier of ["amount", "result", "valueA", "percentage", "angleC"]) { + expect(isShareableField("number", "off", false, identifier)).toBe(true); + } + }); + + it("validates decoded input types instead of coercing untrusted URL data", () => { + expect(isValidRestoredFieldValue("checkbox", true)).toBe(true); + expect(isValidRestoredFieldValue("checkbox", "true")).toBe(false); + expect(isValidRestoredFieldValue("radio", "mode-a")).toBe(true); + expect(isValidRestoredFieldValue("radio", false)).toBe(false); + for (const value of ["2.5", "-3", "1e3", ""]) { + expect(isValidRestoredFieldValue("number", value)).toBe(true); + } + for (const value of ["NaN", "Infinity", "12abc", true, 10, "1".repeat(1801)]) { + expect(isValidRestoredFieldValue("number", value)).toBe(false); + } + expect(isValidRestoredFieldValue("textarea", "text")).toBe(true); + expect(isValidRestoredFieldValue("textarea", null)).toBe(false); + }); + + it("rejects oversized or invalid encoded payload sizes", () => { + expect(isSharePayloadWithinLimit("abc", 3)).toBe(true); + expect(isSharePayloadWithinLimit("abcd", 3)).toBe(false); + expect(isSharePayloadWithinLimit("", 0)).toBe(false); + }); +}); diff --git a/site/src/lib/hooks/share-input-policy.ts b/site/src/lib/hooks/share-input-policy.ts new file mode 100644 index 00000000..68cb5582 --- /dev/null +++ b/site/src/lib/hooks/share-input-policy.ts @@ -0,0 +1,54 @@ +/** + * Baseline exclusions for automatic share URLs. URL query values can appear in + * browser history, copied links, server requests and referrer metadata. + * This is defense in depth, not a guarantee that all remaining inputs are non-PII. + */ +const EXCLUDED_TYPES = new Set([ + "file", "button", "submit", "reset", "image", "password", "hidden", + "email", "tel", +]); + +const SENSITIVE_AUTOCOMPLETE = /^(?:name|honorific-prefix|given-name|additional-name|family-name|honorific-suffix|nickname|username|new-password|current-password|one-time-code|organization-title|organization|street-address|address-line[1-3]|address-level[1-4]|country|country-name|postal-code|cc-.+|transaction-.+|bday(?:-.+)?|sex|tel(?:-.+)?|email|impp|url|photo)$/; + +export function isShareableInputType(type: string): boolean { + return !EXCLUDED_TYPES.has(type.toLowerCase()); +} + +/** Exclude explicitly sensitive/autofilled fields and opt-out controls. */ +/** Field identifiers are developer-controlled hints, not a complete PII classifier. */ +const SENSITIVE_IDENTIFIER = /(?:^|[-_])(?:password|passwd|secret|api[-_]?key|access[-_]?token|refresh[-_]?token|auth[-_]?token|private[-_]?key|credit[-_]?card|card[-_]?number|cvv|cvc|ssn|social[-_]?security|email|phone|telephone|address|postal[-_]?code)(?:$|[-_])/i; + +export function isShareableField( + type: string, + autocomplete = "", + privateField = false, + identifier = "", + explicitTextOptIn = false, +): boolean { + // Free-form inputs are private by default. Opt-in belongs to reviewed tools. + const freeTextType = ["text", "search", "url", "textarea"].includes(type.toLowerCase()); + if (freeTextType && !explicitTextOptIn) return false; + const normalizedIdentifier = identifier.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/\s+/g, "-"); + if (privateField || !isShareableInputType(type) || SENSITIVE_IDENTIFIER.test(normalizedIdentifier)) return false; + const tokens = autocomplete.toLowerCase().trim().split(/\s+/); + return !tokens.some((token) => SENSITIVE_AUTOCOMPLETE.test(token)); +} + +export function isSharePayloadWithinLimit(encoded: string, limit: number): boolean { + return limit > 0 && encoded.length <= limit; +} + +/** + * URL state is untrusted. Do not coerce arbitrary JSON values into DOM controls. + * The caller additionally verifies select/radio values against current options. + */ +export function isValidRestoredFieldValue(type: string, value: unknown): value is string | boolean { + const normalizedType = type.toLowerCase(); + if (normalizedType === "checkbox") return typeof value === "boolean"; + if (normalizedType === "radio") return typeof value === "string" && value.length <= 1800; + if (typeof value !== "string" || value.length > 1800) return false; + if (normalizedType === "number" || normalizedType === "range") { + return value === "" || (value.trim() !== "" && Number.isFinite(Number(value))); + } + return true; +} diff --git a/site/src/lib/hooks/useAutoShareableState.ts b/site/src/lib/hooks/useAutoShareableState.ts index 6f6a6b6b..2fc96424 100644 --- a/site/src/lib/hooks/useAutoShareableState.ts +++ b/site/src/lib/hooks/useAutoShareableState.ts @@ -1,6 +1,7 @@ "use client"; import { useEffect, type RefObject } from "react"; +import { isShareableField, isSharePayloadWithinLimit, isValidRestoredFieldValue } from "./share-input-policy"; /** * ツール本体の DOM 入力を丸ごと ?s= に載せ、共有リンクから復元する。 @@ -32,7 +33,9 @@ import { useEffect, type RefObject } from "react"; * 規約が当てはまらないツールでは `b` が空になり、従来通り入力値だけが復元される * (モードは既定のまま = 壊れるのではなく、共有範囲が狭まるだけ)。 * - * 値は端末を離れない。URL に載るのはユーザーが自分で共有した時だけ。 + * URLクエリは入力中も更新され、履歴・アクセスログ・Referrer等を通して + * 他者に渡る可能性がある。機密入力は fields() で除外するが、残る自由入力は + * 非機密と保証できないため、利用ツールごとに別途プライバシー確認が必要。 */ /** 選択中を表す配色クラス。ToolFrame 配下のツール本体に限って探す。 */ @@ -43,11 +46,17 @@ function fields(root: HTMLElement): HTMLElement[] { return Array.from( root.querySelectorAll("input, select, textarea"), ).filter((el) => { + // Do not share fields explicitly marked private or browser-autofilled as PII. + const privateField = !!el.closest("[data-private], [data-share=\"off\"]"); + const identifier = `${el.getAttribute("name") ?? ""} ${el.id}`; + const explicitTextOptIn = el.getAttribute("data-share") === "on"; if (el instanceof HTMLInputElement) { - // file は値をプログラムから復元できない。button/submit は状態を持たない。 - return !["file", "button", "submit", "reset", "image"].includes(el.type); + return isShareableField(el.type, el.autocomplete, privateField, identifier, explicitTextOptIn); } - return true; + if (el instanceof HTMLTextAreaElement) { + return isShareableField("textarea", el.autocomplete, privateField, identifier, explicitTextOptIn); + } + return isShareableField("select", el.getAttribute("autocomplete") ?? "", privateField, identifier, explicitTextOptIn); }); } @@ -111,6 +120,8 @@ function encodeState(value: object): string { } function decodeState(raw: string): unknown { + // Ignore oversized external URLs before base64 decoding and JSON parsing. + if (!isSharePayloadWithinLimit(raw, MAX_ENCODED)) throw new Error("Share state exceeds size limit"); const b64 = raw.replace(/-/g, "+").replace(/_/g, "/"); const bin = atob(b64); const bytes = new Uint8Array(bin.length); @@ -123,6 +134,16 @@ function buttons(root: HTMLElement): HTMLButtonElement[] { return Array.from(root.querySelectorAll("button")); } +/** Only buttons within an active multi-button mode selector may be replayed from a URL. */ +function isModeButton(btn: HTMLButtonElement): boolean { + const parent = btn.parentElement; + if (!parent || btn.disabled || (btn.type === "submit" && btn.closest("form"))) return false; + const siblings = Array.from(parent.children).filter( + (el): el is HTMLButtonElement => el instanceof HTMLButtonElement, + ); + return siblings.length >= 2 && siblings.some((b) => b.className.includes(ACTIVE_CLASS)); +} + /** 現在の DOM 状態を構造キー→値のオブジェクトに落とす。 */ function snapshot(root: HTMLElement): Record { const seen = new Map(); @@ -135,7 +156,7 @@ function snapshot(root: HTMLElement): Record { } // 選択中モードボタンの index 一覧(カンマ区切り)。該当が無ければキー自体を作らない。 const active = buttons(root) - .map((b, i) => (b.className.includes(ACTIVE_CLASS) ? i : -1)) + .map((b, i) => (isModeButton(b) && b.className.includes(ACTIVE_CLASS) ? i : -1)) .filter((i) => i >= 0); if (active.length > 0) out.b = active.join(","); return out; @@ -169,8 +190,12 @@ export function useAutoShareableState( const k = keyOf(el, seen); const v = saved[k]; // `k in saved` では index 型が絞れないので値を取って判定する。 - if (v === undefined) continue; + if (typeof v !== "string" && typeof v !== "boolean") continue; if (only && !only(el)) continue; + const kind = el instanceof HTMLInputElement ? el.type : el instanceof HTMLSelectElement ? "select" : "textarea"; + if (!isValidRestoredFieldValue(kind, v)) continue; + if (el instanceof HTMLSelectElement && !Array.from(el.options).some((option) => option.value === v)) continue; + if (el instanceof HTMLInputElement && el.type === "radio" && el.value !== v) continue; writeValue(el, v); } }; @@ -183,8 +208,9 @@ export function useAutoShareableState( const all = buttons(root); for (const raw of b.split(",")) { const i = Number(raw); + if (!Number.isSafeInteger(i) || i < 0 || i >= all.length) continue; const btn = all[i]; - if (btn && !btn.className.includes(ACTIVE_CLASS)) btn.click(); + if (btn && isModeButton(btn) && !btn.className.includes(ACTIVE_CLASS)) btn.click(); } } // 2nd: 切替で現れたフィールドを含めて全体を当てる。 @@ -211,10 +237,20 @@ export function useAutoShareableState( timer = window.setTimeout(() => { try { const encoded = encodeState(snapshot(root)); - if (encoded.length > MAX_ENCODED) return; + if (!isSharePayloadWithinLimit(encoded, MAX_ENCODED)) { + // Do not retain a previously generated, now-stale shared result URL. + const stale = new URL(window.location.href); + if (stale.searchParams.has("s")) { + stale.searchParams.delete("s"); + window.history.replaceState(window.history.state, "", stale.toString()); + window.dispatchEvent(new Event("toolify:share-state-change")); + } + return; + } const url = new URL(window.location.href); url.searchParams.set("s", encoded); - window.history.replaceState(null, "", url.toString()); + window.history.replaceState(window.history.state, "", url.toString()); + window.dispatchEvent(new Event("toolify:share-state-change")); } catch { /* 共有できないだけで計算は続行 */ } diff --git a/site/src/lib/hooks/useShareableState.ts b/site/src/lib/hooks/useShareableState.ts index 98904c3a..1a238fe9 100644 --- a/site/src/lib/hooks/useShareableState.ts +++ b/site/src/lib/hooks/useShareableState.ts @@ -13,10 +13,13 @@ import { useEffect, useRef, useState } from "react"; * 履歴が積まれて「戻る」が壊れるため使わない。 * - 初期レンダーでは書かない(useLocalDraft と同様 prevRef ガード)。 * これにより「素の /tools/bmi-calculator」が勝手に ?s= 付き URL に変わらない。 - * - 値は端末を離れない。URL に載るのはユーザー自身が共有した時だけで、 - * サーバーへの送信はどこにも無い(?s= はクライアントでのみ読まれる)。 + * - 注意: ?s= は入力時にブラウザーのURLを更新する。URLは共有時だけでなく + * 履歴、アクセスログ、Referrerなどを通じて外部に伝わる可能性がある。 + * 個人情報や機密値をこのフックの状態に含めないこと。 */ +const MAX_SHARED_STATE_LENGTH = 4096; + /** JSON → base64url。URL に載せても壊れない形にする。 */ function encodeState(value: object): string { const json = JSON.stringify(value); @@ -28,6 +31,7 @@ function encodeState(value: object): string { } function decodeState(raw: string): unknown { + if (raw.length > MAX_SHARED_STATE_LENGTH) throw new Error("Share state exceeds size limit"); const b64 = raw.replace(/-/g, "+").replace(/_/g, "/"); const bin = atob(b64); const bytes = new Uint8Array(bin.length); @@ -72,9 +76,20 @@ export function useShareableState( if (prev === null || prev === serialized) return; try { const encoded = encodeState(JSON.parse(serialized) as object); + if (encoded.length > MAX_SHARED_STATE_LENGTH) { + // Do not let a stale result link represent a newer, unshareable value. + const stale = new URL(window.location.href); + if (stale.searchParams.has("s")) { + stale.searchParams.delete("s"); + window.history.replaceState(window.history.state, "", stale.toString()); + window.dispatchEvent(new Event("toolify:share-state-change")); + } + return; + } const url = new URL(window.location.href); url.searchParams.set("s", encoded); - window.history.replaceState(null, "", url.toString()); + window.history.replaceState(window.history.state, "", url.toString()); + window.dispatchEvent(new Event("toolify:share-state-change")); } catch { /* URL が長すぎる等 — 共有できないだけで計算は続行 */ } diff --git a/site/src/lib/search/search-index-payload.test.ts b/site/src/lib/search/search-index-payload.test.ts new file mode 100644 index 00000000..39c5048d --- /dev/null +++ b/site/src/lib/search/search-index-payload.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { parseSearchIndexPayload } from "./search-index-payload"; + +const tool = { slug: "percentage-calculator", category: "math", title: "Percentage Calculator" }; + +describe("search index payload validation", () => { + it("accepts valid tools in order", () => { + expect(parseSearchIndexPayload({ tools: [tool, { ...tool, slug: "tip-calculator" }] })) + .toEqual([tool, { ...tool, slug: "tip-calculator" }]); + }); + it("does not mistakenly discard structurally valid short slugs", () => { + expect(parseSearchIndexPayload({ tools: [{ ...tool, slug: "s" }] })) + .toEqual([{ ...tool, slug: "s" }]); + }); + it("fails closed for malformed payloads without crashing recommendations", () => { + for (const payload of [null, {}, [], { tools: null }, { tools: "bad" }, 7]) { + expect(parseSearchIndexPayload(payload)).toEqual([]); + } + }); + it("rejects unsafe links, unknown categories, duplicate slugs and oversized labels", () => { + const payload = { tools: [ + { ...tool, slug: "../secret" }, + { ...tool, slug: "other/path" }, + { ...tool, slug: "%2F%2Fevil" }, + { ...tool, category: "unknown" }, + { ...tool, title: " " }, + { ...tool, title: "X".repeat(201) }, + tool, + { ...tool, title: "Duplicate" }, + { ...tool, slug: "tip-calculator" }, + ] }; + expect(parseSearchIndexPayload(payload)) + .toEqual([tool, { ...tool, slug: "tip-calculator" }]); + }); + it("ignores malformed entries without losing valid recommendations", () => { + const data = { tools: [null, "invalid", [], { slug: "", title: "Empty", category: "math" }, { slug: "../unsafe", title: "Unsafe", category: "math" }, tool] }; + expect(parseSearchIndexPayload(data)).toEqual([tool]); + }); +}); diff --git a/site/src/lib/search/search-index-payload.ts b/site/src/lib/search/search-index-payload.ts new file mode 100644 index 00000000..d80df5b8 --- /dev/null +++ b/site/src/lib/search/search-index-payload.ts @@ -0,0 +1,24 @@ +import type { SearchItem } from "./matcher"; + +const VALID_CATEGORIES = new Set([ + "health", "math", "converter", "datetime", "text", "color", "finance", "image", +]); +const VALID_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** Reject malformed index data before building navigation links or labels. */ +export function parseSearchIndexPayload(payload: unknown): SearchItem[] { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return []; + const tools = (payload as { tools?: unknown }).tools; + if (!Array.isArray(tools)) return []; + const unique = new Set(); + return tools.filter((item): item is SearchItem => { + if (!item || typeof item !== "object" || Array.isArray(item)) return false; + const data = item as Record; + if (typeof data.slug !== "string" || data.slug.length > 100 || !VALID_SLUG.test(data.slug)) return false; + if (typeof data.title !== "string" || !data.title.trim() || data.title.length > 200) return false; + if (typeof data.category !== "string" || !VALID_CATEGORIES.has(data.category)) return false; + if (unique.has(data.slug)) return false; + unique.add(data.slug); + return true; + }); +} diff --git a/site/src/lib/search/useSearchIndex.test.ts b/site/src/lib/search/useSearchIndex.test.ts new file mode 100644 index 00000000..3f452306 --- /dev/null +++ b/site/src/lib/search/useSearchIndex.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(new URL("./useSearchIndex.ts", import.meta.url), "utf8"); + +describe("locale-aware search index hook", () => { + it("never displays a previous locale's titles after a locale switch", () => { + expect(source).toContain("setLoaded({ locale, items: list })"); + expect(source).toContain("loaded?.locale === locale"); + expect(source).toContain("return enabled && loaded?.locale === locale ? loaded.items : null"); + }); + it("ignores malformed search index payloads", () => { + expect(source).toContain("parseSearchIndexPayload(payload)"); + expect(source).toContain('from "./search-index-payload"'); + }); +}); diff --git a/site/src/lib/search/useSearchIndex.ts b/site/src/lib/search/useSearchIndex.ts index 9fa0de4b..18f2f414 100644 --- a/site/src/lib/search/useSearchIndex.ts +++ b/site/src/lib/search/useSearchIndex.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import type { SearchItem } from "./matcher"; +import { parseSearchIndexPayload } from "./search-index-payload"; /** * ロケール別の検索インデックス(/{locale}/search-index.json)を遅延取得する。 @@ -19,7 +20,7 @@ export function loadSearchIndex(locale: string): Promise { if (!p) { p = fetch(`/${locale}/search-index.json`) .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) - .then((j: { tools?: SearchItem[] }) => j.tools ?? []) + .then((payload: unknown) => parseSearchIndexPayload(payload)) .catch(() => { cache.delete(locale); return [] as SearchItem[]; @@ -30,16 +31,18 @@ export function loadSearchIndex(locale: string): Promise { } export function useSearchIndex(locale: string, enabled: boolean): SearchItem[] | null { - const [items, setItems] = useState(null); + // Associate data with the locale that produced it to avoid flashing stale labels + // when a visitor switches languages while a different index is loading. + const [loaded, setLoaded] = useState<{ locale: string; items: SearchItem[] } | null>(null); useEffect(() => { if (!enabled) return; let alive = true; loadSearchIndex(locale).then((list) => { - if (alive) setItems(list); + if (alive) setLoaded({ locale, items: list }); }); return () => { alive = false; }; }, [locale, enabled]); - return items; + return enabled && loaded?.locale === locale ? loaded.items : null; } diff --git a/site/src/lib/software/recommendations.ts b/site/src/lib/software/recommendations.ts new file mode 100644 index 00000000..019d0ed6 --- /dev/null +++ b/site/src/lib/software/recommendations.ts @@ -0,0 +1,25 @@ +/** + * Tool-to-software recommendations are a pure mapping so the policy can be + * checked without importing Next.js client components into a Node test runner. + * Keep only products that are actually available in the software catalog. + */ +import { AVAILABLE_SOFTWARE } from "@/lib/software/catalog"; + +export const SOFTWARE_RECOMMENDATIONS: Readonly> = { + "csv-json-converter": ["csv-cleaner-pro", "json-studio"], + "json-formatter": ["json-studio"], + "json-to-yaml-converter": ["json-studio"], + "duplicate-line-remover": ["text-batch-studio"], + "text-sorter": ["text-batch-studio"], + "text-replace": ["text-batch-studio"], + "word-counter": ["text-batch-studio"], + "character-frequency": ["text-batch-studio"], + "reverse-text-generator": ["text-batch-studio"], +}; + +export function availableRecommendations(toolSlug: string) { + const requested = SOFTWARE_RECOMMENDATIONS[toolSlug] ?? []; + return requested + .map((slug) => AVAILABLE_SOFTWARE.find((product) => product.slug === slug)) + .filter((product): product is (typeof AVAILABLE_SOFTWARE)[number] => Boolean(product)); +} diff --git a/site/src/lib/tools/recent-recommendations.test.ts b/site/src/lib/tools/recent-recommendations.test.ts new file mode 100644 index 00000000..b9584cbc --- /dev/null +++ b/site/src/lib/tools/recent-recommendations.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { selectRecentRecommendations } from "./recent-recommendations"; + +const known = new Set(["bmi-calculator", "percentage-calculator", "age-calculator", "length-converter"]); + +describe("selectRecentRecommendations", () => { + it("hides the current page and preserves most-recent order", () => { + expect(selectRecentRecommendations(["bmi-calculator", "age-calculator", "length-converter"], "bmi-calculator", known)) + .toEqual(["age-calculator", "length-converter"]); + }); + + it("deduplicates history and ignores removed tools", () => { + expect(selectRecentRecommendations(["deleted", "age-calculator", "age-calculator", "bmi-calculator"], "bmi-calculator", known)) + .toEqual(["age-calculator"]); + }); + + it("does not duplicate links from the related section", () => { + expect(selectRecentRecommendations( + ["age-calculator", "length-converter", "percentage-calculator"], + "bmi-calculator", + known, + 3, + new Set(["age-calculator", "percentage-calculator"]), + )).toEqual(["length-converter"]); + }); + + it("limits suggestions and returns none for new users", () => { + expect(selectRecentRecommendations([], "bmi-calculator", known)).toEqual([]); + expect(selectRecentRecommendations(["age-calculator", "length-converter", "percentage-calculator"], "bmi-calculator", known, 2)) + .toEqual(["age-calculator", "length-converter"]); + expect(selectRecentRecommendations(["age-calculator"], "bmi-calculator", known, 0)).toEqual([]); + }); +}); diff --git a/site/src/lib/tools/recent-recommendations.ts b/site/src/lib/tools/recent-recommendations.ts new file mode 100644 index 00000000..9458ffd0 --- /dev/null +++ b/site/src/lib/tools/recent-recommendations.ts @@ -0,0 +1,23 @@ +/** + * Keep a small, deterministic return path on the tool detail page. + * Never recommend the current page, duplicate entries, or unrecognized slugs. + * All history consists of slug identifiers, not calculator inputs. + */ +export function selectRecentRecommendations( + recent: readonly string[], + currentSlug: string, + availableSlugs: ReadonlySet, + limit = 3, + excludedSlugs: ReadonlySet = new Set(), +): string[] { + if (limit <= 0) return []; + const seen = new Set([currentSlug]); + const selected: string[] = []; + for (const slug of recent) { + if (seen.has(slug) || excludedSlugs.has(slug) || !availableSlugs.has(slug)) continue; + seen.add(slug); + selected.push(slug); + if (selected.length >= limit) break; + } + return selected; +} diff --git a/site/src/tools/percentage-calculator/Component.tsx b/site/src/tools/percentage-calculator/Component.tsx index 074131dc..0971e014 100644 --- a/site/src/tools/percentage-calculator/Component.tsx +++ b/site/src/tools/percentage-calculator/Component.tsx @@ -1,44 +1,48 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { scenarioResult, type PercentageMode } from "./scenario-comparison"; +import { parseSharedPercentageState } from "./scenario-share"; +import { scenarioCopy, resumeCopy } from "./scenario-copy"; +import { SCENARIO_STORAGE_KEY, parseSavedScenario, serializeSavedScenario, type SavedPercentageScenario } from "./scenario-resume"; import { useLocale, useTranslations } from "next-intl"; import { useShareableState } from "@/lib/hooks/useShareableState"; +import { trackScenarioAction } from "@/lib/analytics/events"; -type Mode = "pct_of" | "what_pct" | "pct_change" | "pct_add" | "pct_sub"; +type Mode = PercentageMode; const MODES: Mode[] = ["pct_of", "what_pct", "pct_change", "pct_add", "pct_sub"]; export default function PercentageCalculator() { const t = useTranslations("tools.percentage-calculator"); const locale = useLocale(); + const comparisonText = scenarioCopy(locale); + const resumeText = resumeCopy(locale); const [mode, setMode] = useState("pct_of"); // 空欄で着地すると結果カードが何も描かれず、道具が動くことが伝わらないまま離脱する。 // 代表値を初期表示し、最初の描画から結果を見せる(2026-08-22)。 const [a, setA] = useState("20"); const [b, setB] = useState("250"); + const [baseline, setBaseline] = useState<{ mode: Mode; a: string; b: string; value: number } | null>(null); + const [saved, setSaved] = useState(null); + + useEffect(() => { + try { + const raw = window.localStorage.getItem(SCENARIO_STORAGE_KEY); + const value = parseSavedScenario(raw, Date.now()); + setSaved(value); + if (raw && !value) window.localStorage.removeItem(SCENARIO_STORAGE_KEY); + } catch { /* Browser storage may be disabled; calculator remains functional. */ } + }, []); useShareableState("percentage-calculator", { mode, a, b }, (d) => { - if (typeof d.mode === "string" && (MODES as string[]).includes(d.mode)) setMode(d.mode as Mode); - if (typeof d.a === "string") setA(d.a); - if (typeof d.b === "string") setB(d.b); + const shared = parseSharedPercentageState(d); + if (!shared) return; + setMode(shared.mode); + setA(shared.a); + setB(shared.b); }); - const result = useMemo(() => { - const x = parseFloat(a); - const y = parseFloat(b); - if (!isFinite(x) || !isFinite(y)) return null; - switch (mode) { - case "pct_of": - return (x / 100) * y; - case "what_pct": - return y === 0 ? null : (x / y) * 100; - case "pct_change": - return x === 0 ? null : ((y - x) / x) * 100; - case "pct_add": - return y * (1 + x / 100); - case "pct_sub": - return y * (1 - x / 100); - } - }, [mode, a, b]); + const result = useMemo(() => scenarioResult(mode, a, b), [mode, a, b]); const fmt = useMemo(() => new Intl.NumberFormat(locale, { maximumFractionDigits: 4 }), [locale]); // 加算・減算モードは「X% of Y」と同じ入力欄(X=率, Y=値)なので文言を流用する。 @@ -50,11 +54,12 @@ export default function PercentageCalculator() { return (
-
+
{MODES.map((m) => (
+
+
+ + {saved && <> + + + } +
+

{resumeText.disclosure}

+
+ {/* Temporary comparison: no automatic storage, analytics payload, or server transfer. */} +
+

{comparisonText.heading}

+
+ + {baseline && } + {baseline && } +
+ {baseline && ( +
+
A · {t(`mode.${baseline.mode}.title`)}
{fmt.format(baseline.value)}
+
B · {t(`mode.${mode}.title`)}
{result === null ? "—" : fmt.format(result)}
+
{comparisonText.difference}
{result === null || baseline.mode !== mode ? "—" : fmt.format(result - baseline.value)}
+
+ )} +
); } diff --git a/site/src/tools/percentage-calculator/scenario-comparison.test.ts b/site/src/tools/percentage-calculator/scenario-comparison.test.ts new file mode 100644 index 00000000..ced07436 --- /dev/null +++ b/site/src/tools/percentage-calculator/scenario-comparison.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { scenarioResult } from "./scenario-comparison"; + +describe("percentage scenarios", () => { + it("computes all supported modes consistently", () => { + expect(scenarioResult("pct_of", "20", "250")).toBe(50); + expect(scenarioResult("what_pct", "20", "250")).toBe(8); + expect(scenarioResult("pct_change", "20", "250")).toBe(1150); + expect(scenarioResult("pct_add", "20", "250")).toBe(300); + expect(scenarioResult("pct_sub", "20", "250")).toBe(200); + }); + it("handles empty, zero denominator and non-finite values", () => { + expect(scenarioResult("pct_of", "", "250")).toBeNull(); + expect(scenarioResult("pct_of", " ", "250")).toBeNull(); + expect(scenarioResult("pct_of", "20xyz", "250")).toBeNull(); + expect(scenarioResult("pct_of", "0x10", "250")).toBeNull(); + expect(scenarioResult("pct_of", "20%", "250")).toBeNull(); + expect(scenarioResult("pct_of", "-2.5", "100")).toBe(-2.5); + expect(scenarioResult("what_pct", "20", "0")).toBeNull(); + expect(scenarioResult("pct_change", "0", "20")).toBeNull(); + expect(scenarioResult("pct_of", "Infinity", "250")).toBeNull(); + expect(scenarioResult("pct_of", "1e308", "1e308")).toBeNull(); + }); +}); diff --git a/site/src/tools/percentage-calculator/scenario-comparison.ts b/site/src/tools/percentage-calculator/scenario-comparison.ts new file mode 100644 index 00000000..189c6d80 --- /dev/null +++ b/site/src/tools/percentage-calculator/scenario-comparison.ts @@ -0,0 +1,21 @@ +export type PercentageMode = "pct_of" | "what_pct" | "pct_change" | "pct_add" | "pct_sub"; + +/** Pure calculation shared by the live result and A/B comparison. */ +export function scenarioResult(mode: PercentageMode, a: string, b: string): number | null { + // Number.parseFloat("20abc") silently accepts 20; reject malformed input instead. + if (!a.trim() || !b.trim()) return null; + const decimal = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/; + if (!decimal.test(a.trim()) || !decimal.test(b.trim())) return null; + const x = Number(a.trim()); + const y = Number(b.trim()); + if (!Number.isFinite(x) || !Number.isFinite(y)) return null; + let value: number; + switch (mode) { + case "pct_of": value = (x / 100) * y; break; + case "what_pct": if (y === 0) return null; value = (x / y) * 100; break; + case "pct_change": if (x === 0) return null; value = ((y - x) / x) * 100; break; + case "pct_add": value = y * (1 + x / 100); break; + case "pct_sub": value = y * (1 - x / 100); break; + } + return Number.isFinite(value) ? value : null; +} diff --git a/site/src/tools/percentage-calculator/scenario-copy.test.ts b/site/src/tools/percentage-calculator/scenario-copy.test.ts new file mode 100644 index 00000000..c7e28fc0 --- /dev/null +++ b/site/src/tools/percentage-calculator/scenario-copy.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { LOCALES } from "@/lib/i18n/locales"; +import { resumeCopy, scenarioCopy } from "./scenario-copy"; + +describe("scenario action localization", () => { + it.each(LOCALES)("provides every action label in %s", (locale) => { + const compare = scenarioCopy(locale); + const resume = resumeCopy(locale); + expect(Object.values(compare)).toHaveLength(5); + expect(Object.values(resume)).toHaveLength(4); + for (const label of [...Object.values(compare), ...Object.values(resume)]) { + expect(typeof label).toBe("string"); + expect(label.trim().length).toBeGreaterThan(0); + } + }); + + it("uses English for unknown locales", () => { + expect(scenarioCopy("unknown")).toEqual(scenarioCopy("en")); + expect(resumeCopy("unknown")).toEqual(resumeCopy("en")); + }); +}); diff --git a/site/src/tools/percentage-calculator/scenario-copy.ts b/site/src/tools/percentage-calculator/scenario-copy.ts new file mode 100644 index 00000000..ccf2c8f8 --- /dev/null +++ b/site/src/tools/percentage-calculator/scenario-copy.ts @@ -0,0 +1,50 @@ +/** Compact A/B controls for all active locales. */ +const COPY = { + en: ["Compare scenarios", "Save current as A", "Restore A", "Clear A", "Difference (B − A)"], + ja: ["条件を比較", "現在の条件をAに保存", "Aに戻す", "比較を解除", "差分 (B − A)"], + "zh-CN": ["比较方案", "将当前方案保存为A", "恢复A", "清除A", "差值 (B − A)"], + "zh-TW": ["比較方案", "將目前方案儲存為A", "還原A", "清除A", "差值 (B − A)"], + ko: ["조건 비교", "현재 조건을 A로 저장", "A 복원", "A 지우기", "차이 (B − A)"], + es: ["Comparar escenarios", "Guardar como A", "Restaurar A", "Borrar A", "Diferencia (B − A)"], + "pt-BR": ["Comparar cenários", "Salvar como A", "Restaurar A", "Limpar A", "Diferença (B − A)"], + fr: ["Comparer les scénarios", "Enregistrer comme A", "Restaurer A", "Effacer A", "Différence (B − A)"], + de: ["Szenarien vergleichen", "Als A speichern", "A wiederherstellen", "A löschen", "Differenz (B − A)"], + it: ["Confronta scenari", "Salva come A", "Ripristina A", "Cancella A", "Differenza (B − A)"], + ru: ["Сравнить сценарии", "Сохранить как A", "Восстановить A", "Очистить A", "Разница (B − A)"], + ar: ["مقارنة السيناريوهات", "حفظ الحالي كـ A", "استعادة A", "مسح A", "الفرق (B − A)"], + hi: ["परिदृश्यों की तुलना करें", "वर्तमान को A के रूप में सहेजें", "A पुनर्स्थापित करें", "A हटाएँ", "अंतर (B − A)"], + id: ["Bandingkan skenario", "Simpan sebagai A", "Pulihkan A", "Hapus A", "Selisih (B − A)"], + th: ["เปรียบเทียบเงื่อนไข", "บันทึกเป็น A", "คืนค่า A", "ล้าง A", "ผลต่าง (B − A)"], + vi: ["So sánh kịch bản", "Lưu hiện tại làm A", "Khôi phục A", "Xóa A", "Chênh lệch (B − A)"], + tr: ["Senaryoları karşılaştır", "A olarak kaydet", "A'yı geri yükle", "A'yı temizle", "Fark (B − A)"], +} as const; + +export function scenarioCopy(locale: string) { + const labels = COPY[locale as keyof typeof COPY] ?? COPY.en; + return { heading: labels[0], save: labels[1], restore: labels[2], clear: labels[3], difference: labels[4] }; +} + +const RESUME_COPY: Record = { + en: ["Save for later", "Resume saved", "Delete saved", "Saved on this device for up to 7 days"], + ja: ["後で再開するため保存", "保存した条件を再開", "保存内容を削除", "この端末に最大7日間保存します"], + "zh-CN": ["保存以便稍后继续", "恢复已保存内容", "删除保存内容", "在本设备最多保存7天"], + "zh-TW": ["儲存以便稍後繼續", "還原已儲存內容", "刪除儲存內容", "在此裝置最多儲存7天"], + ko: ["나중에 계속하기 위해 저장", "저장한 내용 복원", "저장 내용 삭제", "이 기기에 최대 7일 저장"], + es: ["Guardar para después", "Recuperar guardado", "Eliminar guardado", "Se guarda en este dispositivo hasta 7 días"], + "pt-BR": ["Salvar para depois", "Retomar salvo", "Excluir salvo", "Armazenado neste dispositivo por até 7 dias"], + fr: ["Enregistrer pour plus tard", "Reprendre la sauvegarde", "Supprimer la sauvegarde", "Enregistré sur cet appareil pendant 7 jours maximum"], + de: ["Für später speichern", "Gespeichertes fortsetzen", "Gespeichertes löschen", "Bis zu 7 Tage auf diesem Gerät gespeichert"], + it: ["Salva per dopo", "Riprendi il salvataggio", "Elimina il salvataggio", "Salvato su questo dispositivo per massimo 7 giorni"], + ru: ["Сохранить на потом", "Восстановить сохранённое", "Удалить сохранённое", "Хранится на этом устройстве до 7 дней"], + ar: ["حفظ لوقت لاحق", "استعادة المحفوظ", "حذف المحفوظ", "يُحفظ على هذا الجهاز لمدة تصل إلى 7 أيام"], + hi: ["बाद के लिए सहेजें", "सहेजा हुआ पुनः खोलें", "सहेजा हुआ हटाएँ", "इस डिवाइस पर अधिकतम 7 दिनों तक सहेजा जाता है"], + id: ["Simpan untuk nanti", "Lanjutkan tersimpan", "Hapus tersimpan", "Disimpan di perangkat ini hingga 7 hari"], + th: ["บันทึกไว้ใช้ภายหลัง", "กลับไปยังที่บันทึก", "ลบข้อมูลที่บันทึก", "บันทึกในอุปกรณ์นี้สูงสุด 7 วัน"], + vi: ["Lưu để dùng sau", "Tiếp tục bản lưu", "Xóa bản lưu", "Lưu trên thiết bị này tối đa 7 ngày"], + tr: ["Sonrası için kaydet", "Kaydı sürdür", "Kaydı sil", "Bu cihazda en fazla 7 gün saklanır"], +}; + +export function resumeCopy(locale: string) { + const labels = RESUME_COPY[locale] ?? RESUME_COPY.en!; + return { save: labels[0], restore: labels[1], clear: labels[2], disclosure: labels[3] }; +} diff --git a/site/src/tools/percentage-calculator/scenario-resume.test.ts b/site/src/tools/percentage-calculator/scenario-resume.test.ts new file mode 100644 index 00000000..67ed5666 --- /dev/null +++ b/site/src/tools/percentage-calculator/scenario-resume.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { parseSavedScenario, serializeSavedScenario } from "./scenario-resume"; + +describe("percentage resume draft", () => { + const now = 1793900000000; + it("round trips a deliberately saved scenario", () => { + const saved = serializeSavedScenario("pct_change", "20", "250", now); + expect(parseSavedScenario(saved, now)).toEqual({ mode: "pct_change", a: "20", b: "250", savedAt: now }); + }); + it("rejects expired, future, malformed or unexpectedly large data", () => { + const saved = serializeSavedScenario("pct_of", "20", "250", now); + expect(parseSavedScenario(saved, now + 8 * 86400000)).toBeNull(); + expect(parseSavedScenario(saved, now - 1)).toBeNull(); + expect(parseSavedScenario("{bad", now)).toBeNull(); + expect(parseSavedScenario(JSON.stringify({ mode: "unexpected", a: "1", b: "2", savedAt: now }), now)).toBeNull(); + expect(parseSavedScenario("x".repeat(513), now)).toBeNull(); + expect(serializeSavedScenario("pct_of", "20