diff --git a/.changeset/question-lease-and-labels.md b/.changeset/question-lease-and-labels.md new file mode 100644 index 00000000..a5dd49e0 --- /dev/null +++ b/.changeset/question-lease-and-labels.md @@ -0,0 +1,5 @@ +--- +'@pymodel/pythinker-code': patch +--- + +Questions no longer expire after 60 seconds, expired questions are not reported as user dismissals, answers retain question text and option labels, and Escape no longer dismisses a question. diff --git a/apps/pythinker-web/AGENTS.md b/apps/pythinker-web/AGENTS.md index 2f7e0784..7206cfba 100644 --- a/apps/pythinker-web/AGENTS.md +++ b/apps/pythinker-web/AGENTS.md @@ -23,13 +23,13 @@ The browser web UI for Pythinker Code — a peer to the TUI in `apps/pythinker-c - Shared components go in `src/components/`; reusable logic goes in `src/composables/` with a `use` prefix. - There is **no auto-import plugin** and **no path alias** — `#/` and `@/` are intentionally unused. Write relative imports (`../i18n`, `./config`). -## i18n (normative — keeping locales in sync is manual) +## i18n (normative — the app is English-only) - Setup: `src/i18n/index.ts`, vue-i18n in Composition mode (`legacy: false`), fallback `en`. The active locale is persisted in `localStorage` under `pythinker-locale`. -- Locale files: `src/i18n/locales/{en,zh}/.ts`, each `export default { ... } as const`. New namespaces are registered in `src/i18n/locales/index.ts`. +- **`en` is the only locale.** `src/i18n/locales/` contains exactly one directory, and `locales/index.ts` registers only `en`. Do not add a second locale, and do not "restore parity" with one that does not exist. +- Locale files: `src/i18n/locales/en/.ts`, each `export default { ... } as const`. New namespaces are registered in `src/i18n/locales/index.ts`. - Reference with `const { t } = useI18n()` and `t('namespace.key')` (same form in templates). -- **Adding a key:** add it to **both** `en/.ts` and `zh/.ts`. **Adding a namespace:** create the file in both locales **and** register it in `locales/index.ts`. -- There is **no automated missing-key or en/zh parity check**. Keeping the two locales in sync is a manual responsibility — do not leave a key present in only one locale. +- **Adding a key:** add it to `en/.ts`. **Adding a namespace:** create the file under `en/` **and** register it in `locales/index.ts`. ## Commands diff --git a/apps/pythinker-web/src/components/QuestionCard.vue b/apps/pythinker-web/src/components/QuestionCard.vue index 829b4aeb..06eea682 100644 --- a/apps/pythinker-web/src/components/QuestionCard.vue +++ b/apps/pythinker-web/src/components/QuestionCard.vue @@ -30,6 +30,22 @@ const total = computed(() => props.question.questions.length); const hasPreview = computed(() => current.value.options.some((option) => option.preview?.trim()), ); +const now = ref(Date.now()); +const remainingMinutes = computed(() => { + const expiresAt = Date.parse(props.question.expiresAt); + if (Number.isNaN(expiresAt)) return undefined; + return Math.ceil((expiresAt - now.value) / 60_000); +}); +const leaseWarning = computed(() => { + const expiresAt = Date.parse(props.question.expiresAt); + if (Number.isNaN(expiresAt)) return undefined; + const remainingMs = expiresAt - now.value; + if (remainingMs <= 0 || remainingMs >= 5 * 60_000) return undefined; + if (remainingMs < 60_000) return t('question.expiresSoonSeconds'); + const minutes = remainingMinutes.value; + if (minutes === undefined) return undefined; + return t('question.expiresSoon', { minutes }); +}); function goBack(): void { if (step.value > 0) step.value--; @@ -207,17 +223,15 @@ function dismiss(): void { } // --------------------------------------------------------------------------- -// Keyboard: number keys pick options for current question, Enter submit, Esc dismiss +// Keyboard: number keys pick options for the current question and Enter submits. // --------------------------------------------------------------------------- function handleKeydown(e: KeyboardEvent): void { const tag = (document.activeElement?.tagName ?? '').toLowerCase(); if (tag === 'input' || tag === 'textarea') return; - // While minimized the options aren't visible, so don't let number keys pick - // an unseen answer; only Escape (dismiss) stays live. - if (minimized.value && e.key !== 'Escape') return; + // While minimized the options are not visible, so keyboard selection is disabled. + if (minimized.value) return; - if (e.key === 'Escape') { e.preventDefault(); dismiss(); return; } if (e.key === 'Enter') { e.preventDefault(); submit(); return; } const num = parseInt(e.key, 10); @@ -236,8 +250,19 @@ function handleKeydown(e: KeyboardEvent): void { } } -onMounted(() => document.addEventListener('keydown', handleKeydown)); -onUnmounted(() => document.removeEventListener('keydown', handleKeydown)); +let leaseTimer: ReturnType | undefined; + +onMounted(() => { + document.addEventListener('keydown', handleKeydown); + leaseTimer = setInterval(() => { + now.value = Date.now(); + }, 30_000); +}); + +onUnmounted(() => { + document.removeEventListener('keydown', handleKeydown); + if (leaseTimer !== undefined) clearInterval(leaseTimer); +});