From 53a15da119799989e5bd2d58bf7f32aac4e6468f Mon Sep 17 00:00:00 2001 From: Aleksejs Kovalovs Date: Sun, 4 Oct 2026 05:11:51 +0300 Subject: [PATCH] Meeting list: banner for a meeting open longer than its period (#204) The list shows one line per such meeting, with a link to the meeting page's archive form (the follow-up email's #close fragment). For such a meeting the archive form suggests the nearest date on the pair's cadence that isn't in the past, instead of today plus one period. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 + docs/history.md | 1 + docs/user-flow.md | 2 +- frontend/e2e/dual-actor-anketa.spec.ts | 34 +++++++ frontend/src/anketa/longOpen.test.ts | 132 +++++++++++++++++++++++++ frontend/src/anketa/longOpen.ts | 46 +++++++++ frontend/src/i18n/locales/de.json | 1 + frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/es.json | 1 + frontend/src/i18n/locales/fr.json | 1 + frontend/src/i18n/locales/lv.json | 1 + frontend/src/i18n/locales/ru.json | 1 + frontend/src/pages/Anketa.svelte | 15 ++- frontend/src/pages/AnketaList.svelte | 55 +++++++++++ 14 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 frontend/src/anketa/longOpen.test.ts create mode 100644 frontend/src/anketa/longOpen.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8a965ee..6967710 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,8 @@ Follow-up email after an unclosed meeting ([GitHub issue #202](https://github.co Permanent per-pair calendar link ([GitHub issue #203](https://github.com/aleksejs1/encrypted1on1/issues/203), part of #213), frontend only: `/pair/{userIdA}/{userIdB}` (`pages/PairMeeting.svelte`; `pairPath()` sorts the ids, so both participants share one link, and `pairCounterpartId()` takes the one that isn't the caller's) redirects to the pair's open chain meeting, picked by `pairMeeting()` in `anketa/pairChain.ts` (a wrapper over `pairChainState()`, so one-offs and roles are ignored like everywhere else); with none open it shows the last closed one and "Schedule the next one", which opens the create form with the colleague preselected (`startCreateWith()` in `createDefaults.ts`). The meeting page's "Calendar link" button (`AnketaHeader.svelte`) copies it. See `docs/decisions/2026-10-03-pair-calendar-link.md`. +Banner for a long-open meeting ([GitHub issue #204](https://github.com/aleksejs1/encrypted1on1/issues/204), part of #213), frontend only: the meeting list shows a line with a "Close and schedule the next one" link (to the archive form, via the follow-up email's `#close` fragment) for each meeting open longer than its period. `frontend/src/anketa/longOpen.ts` holds the rule (`isOpenPastPeriod()`) and the suggested next date (`nextCadenceDate()`, the nearest date on the pair's cadence that isn't in the past), which `Anketa.svelte`'s `load()` uses as the archive form's default for such a meeting however the page was opened; other meetings keep today plus one period. + **For the full phase-by-phase and decision-by-decision history — what was built, why, what was tried and rejected, and exactly how each change was verified against a real running stack — see [`docs/history.md`](docs/history.md).** That file carries the detailed record this section used to hold directly; it was split out so a session doesn't have to load the entire development history into context to find the handful of constraints below that are actually still load-bearing. `docs/adr/` holds the ~8 genuinely foundational, hard-to-reverse architecture decisions; `docs/decisions/` holds shorter records of non-trivial decisions made after the split — check both before re-deriving a decision that's already been made. The detailed spec currently lives outside this repo as a local working document (not tracked in git); an English version is planned to land here (likely `docs/SPEC.md`) as a separate task. Until that exists, treat the constraints below as authoritative, and ask before assuming anything not covered here. diff --git a/docs/history.md b/docs/history.md index 5c961dc..44ba5f0 100644 --- a/docs/history.md +++ b/docs/history.md @@ -171,3 +171,4 @@ Phases 1–5, 6a–6k, and 7a–7f are all done. **Phase 8 (visual design system - **Sessions survive deploys, 12-hour idle timeout** — closes [GitHub issue #194](https://github.com/aleksejs1/encrypted1on1/issues/194), part of the product-adoption tracking issue [#213](https://github.com/aleksejs1/encrypted1on1/issues/213). Session files moved from PHP's default (the container's tmp, lost on every recreate) to `var/sessions`, inside the volume every topology mounts at `/app/var` (`framework.session.handler_id`/`save_path`). `AuthSession` now takes a `ClockInterface`, stamps `last_active_at` on login and on every successful `getCurrentUser()`, and logs out a session idle for more than `IDLE_TIMEOUT_SECONDS` (12 hours); a logged-in session with no stamp counts as idle. `gc_maxlifetime` is 48 hours, disk cleanup only. `cookie_lifetime` stays `0`. An idle logout removes only the login from the session, keeping its CSRF secret: the check runs before the CSRF listener, and invalidating the session there turned an idle tab's first write into a 403 the client doesn't treat as a logout (found in `code-review` round 1). No migration, no frontend change (an idle logout is the 401 the client already handles). Verified with a mocked clock in unit and functional tests, and on the dev stack across `docker compose up --force-recreate`; production verification is still owed after the deploy. See `docs/decisions/2026-10-03-sessions-survive-deploys-idle-timeout.md`. - **Follow-up email after a meeting nobody closed** — closes [GitHub issue #202](https://github.com/aleksejs1/encrypted1on1/issues/202), part of the product-adoption tracking issue [#213](https://github.com/aleksejs1/encrypted1on1/issues/213). `app:send-reminders` gains a follow-up pass: a meeting still open after its day gets one "Did your 1:1 happen?" email per participant on the next business day (`email.meeting_follow_up`, all 6 locales, `AnketaNotifier::notifyMeetingFollowUp()`, gated by `User::wantsMeetingReminders()`; a one-off gets a `body_one_off` without "schedule the next one"). Weekend runs send none, so Friday's and the weekend's meetings are followed up on Monday. A wider look-back on every weekday run (three, then five days), meant as an automatic retry, was tried and dropped across two review rounds: a release re-sends to both participants, so one permanently rejecting mailbox meant a repeated follow-up for the other on every run in the window. A failed follow-up is retried by a same-day rerun, like a reminder. Claimed per meeting day through a new `Anketa::$followUpMeetingDay` (SQLite migration generated and trimmed, MySQL one hand-written, not backfilled) and `AnketaRepository::findDueForFollowUp()`/`claimFollowUp()`/`releaseFollowUp()`; the command's passes became a `ReminderPass` enum over the one existing loop. The email's two links differ by URL fragment (`#close`, `#reschedule`, `frontend/src/anketa/followUpLinks.ts`): the anketa page scrolls to the archive form after loading, or focuses the reschedule date field, then drops the fragment; both also work on `hashchange`. `goToArchiveSection()` moved from `AnketaHeader` to `archiveHeading.ts` to be shared. The account page's reminder-toggle hint, `docs/deployment.md`, `docs/user-flow.md` and `docs/encryption.md` are updated. See `docs/decisions/2026-10-03-follow-up-email-after-unclosed-meeting.md`. - **Permanent per-pair calendar link** — closes [GitHub issue #203](https://github.com/aleksejs1/encrypted1on1/issues/203), part of the product-adoption tracking issue [#213](https://github.com/aleksejs1/encrypted1on1/issues/213). Frontend only. A new route `/pair/{userIdA}/{userIdB}` (`PAIR_PATTERN`/`pairPath()` in `routes.ts`, ids sorted so both people get the same link; `pages/PairMeeting.svelte`) takes the id that isn't the caller's as the counterpart (`pairCounterpartId()`), loads the caller's meeting list and, via a new `pairMeeting()` in `anketa/pairChain.ts` (over the existing `pairChainState()`), redirects to the pair's open chain meeting, replacing the history entry; with none open it shows the last closed meeting with "Schedule the next one" and "Open the last 1:1", and with no chain meeting at all "No 1:1 behind this link". "Schedule the next one" preselects the colleague on the create form through an in-memory hand-over (`startCreateWith()`/`takeCreateWith()` in `createDefaults.ts`); `UserTypeahead` now shows a value set from outside. The meeting page's header gets a "Calendar link" button that copies the URL and shows a hint, reusing the template share link's `copyToClipboard()`/`CopyableLink`; hidden for a deleted colleague. All 6 locales, written by Claude, not native-speaker-reviewed. The issue's two open questions: the URL names both people instead of the proposed `/with/{counterpartUserId}` (built first, then changed after `code-review` round 1 noted that a counterpart-only link works for just one of the two, while a recurring 1:1 is one shared calendar event), and a pair has one chain whichever role leads it, so there is no direction to choose. Verified by unit tests and a dual-actor Playwright test across two cycles on the real e2e stack. Three `code-review` rounds: round 1 led to the two-id URL, round 2 found that the redirect could fire after the page was left (the unlock isn't cancelled by `abortOnDestroy()`), round 3 came back clean. Two small gaps are left open and listed in `docs/decisions/2026-10-03-pair-calendar-link.md`. +- **List banner for a meeting left open longer than its period** — closes [GitHub issue #204](https://github.com/aleksejs1/encrypted1on1/issues/204), part of the product-adoption tracking issue [#213](https://github.com/aleksejs1/encrypted1on1/issues/213). Frontend only. `AnketaList.svelte` shows one line per such meeting above the list ("{name}: the 1:1 on {date} isn't closed yet."), with a "Close and schedule the next one" link to the meeting page's archive form (the follow-up email's `#close` fragment, so no new landing code). The rule and the suggested date are two pure functions in `frontend/src/anketa/longOpen.ts`: `isOpenPastPeriod()` (open, not a one-off, more than `periodicityDays` past its date) and `nextCadenceDate()` (the meeting date plus the fewest whole periods that reach today). `Anketa.svelte`'s `load()` uses the cadence date as the archive form's default for such a meeting however the page was opened, not only from the banner; every other meeting keeps today plus one period. The banner text adds the counterpart's name to the issue's wording, since several meetings can be listed, and skips a meeting whose counterpart was deleted. Verified with unit tests for both functions and a new e2e test in `dual-actor-anketa.spec.ts` against the e2e stack. diff --git a/docs/user-flow.md b/docs/user-flow.md index 8cdd1d1..c40ebe8 100644 --- a/docs/user-flow.md +++ b/docs/user-flow.md @@ -38,7 +38,7 @@ A toggle switches between two views: - **By date** — a flat, chronological list across every counterpart. - **By person** — grouped by counterpart, each group showing that pair's full archived history plus two small trend sparklines (mood, workload) built from your own past answers with them, so a shift over many meetings is visible at a glance instead of buried inside individual 1:1s. Both are computed entirely in the browser from 1:1s you can already see — nothing new happens on the server for this. -If any of your 1:1s were sealed under a counterpart's now-outdated encryption key (see "forgetting your password" above), a banner appears at the top with a one-click "re-share access" action. A 1:1 whose meeting date has passed but which isn't archived yet gets a neutral "not closed" badge here, regardless of which view you're in. A still-open 1:1 using a meeting type other than the regular check-in (e.g. "Career growth") shows that type next to its date, in both views. +If any of your 1:1s were sealed under a counterpart's now-outdated encryption key (see "forgetting your password" above), a banner appears at the top with a one-click "re-share access" action. A 1:1 whose meeting date has passed but which isn't archived yet gets a neutral "not closed" badge here, regardless of which view you're in. One left open for longer than its period (say, over a vacation) also gets a line above the list with a "Close and schedule the next one" link: it opens the meeting's archive form, where the suggested next date is the nearest one on the pair's cadence that isn't in the past. A still-open 1:1 using a meeting type other than the regular check-in (e.g. "Career growth") shows that type next to its date, in both views. ## Starting a new 1:1 diff --git a/frontend/e2e/dual-actor-anketa.spec.ts b/frontend/e2e/dual-actor-anketa.spec.ts index 6d95341..46d7a06 100644 --- a/frontend/e2e/dual-actor-anketa.spec.ts +++ b/frontend/e2e/dual-actor-anketa.spec.ts @@ -2874,3 +2874,37 @@ test("a pair's calendar link follows the chain across cycles", async ({ manager.getByRole('heading', { name: 'No 1:1 behind this link' }), ).toBeVisible(); }); + +/** + * GitHub issue #204: a meeting left open for longer than its period gets a + * banner in the list, and its link opens the archive form with a next date + * back on the pair's cadence instead of in the past. + */ +test('a meeting open for longer than its period gets a list banner leading to the archive form', async ({ + browser, +}) => { + const employeeEmail = uniqueEmail('employee-long-open'); + const managerEmail = uniqueEmail('manager-long-open'); + const employee = await activate(browser, createActivationLink(employeeEmail)); + await activate(browser, createActivationLink(managerEmail)); + + // 20 days back, with the form's default weekly period: three periods on is + // tomorrow, the first cadence date that isn't in the past. + const anketaUrl = await createAnketa(employee, managerEmail, -20); + await employee.goto('/'); + const banner = employee.locator('.long-open'); + await expect(banner).toContainText(managerEmail); + await expect(banner).toContainText("isn't closed yet"); + await banner + .getByRole('link', { name: 'Close and schedule the next one' }) + .click(); + await expect(employee.locator('#archive-heading')).toBeFocused(); + await expect(employee).toHaveURL(anketaUrl); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const dd = String(tomorrow.getDate()).padStart(2, '0'); + const mm = String(tomorrow.getMonth() + 1).padStart(2, '0'); + await expect(employee.locator('#next-meeting-date')).toHaveValue( + `${dd}.${mm}.${tomorrow.getFullYear()}`, + ); +}); diff --git a/frontend/src/anketa/longOpen.test.ts b/frontend/src/anketa/longOpen.test.ts new file mode 100644 index 0000000..c0b01f2 --- /dev/null +++ b/frontend/src/anketa/longOpen.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import { isOpenPastPeriod, nextCadenceDate } from './longOpen'; + +// Midday, so the local calendar date is 2026-08-25 in any test timezone. +const NOW = new Date('2026-08-25T12:00:00Z'); + +function meeting( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + archivedAt: null, + meetingDate: '2026-08-01T00:00:00.000Z', + periodicityDays: 14, + oneOff: false, + ...overrides, + }; +} + +describe('isOpenPastPeriod', () => { + it('is true once more than one period has passed since the meeting date', () => { + // 15 days before NOW. + expect( + isOpenPastPeriod( + meeting({ meetingDate: '2026-08-10T00:00:00.000Z' }), + NOW, + ), + ).toBe(true); + }); + + it('is false exactly one period after the meeting date', () => { + expect( + isOpenPastPeriod( + meeting({ meetingDate: '2026-08-11T00:00:00.000Z' }), + NOW, + ), + ).toBe(false); + }); + + it('is false for a meeting that is merely past its date', () => { + expect( + isOpenPastPeriod( + meeting({ meetingDate: '2026-08-24T00:00:00.000Z' }), + NOW, + ), + ).toBe(false); + }); + + it('is false for an upcoming meeting', () => { + expect( + isOpenPastPeriod( + meeting({ meetingDate: '2026-09-01T00:00:00.000Z' }), + NOW, + ), + ).toBe(false); + }); + + it('is false for an archived meeting', () => { + expect( + isOpenPastPeriod( + meeting({ archivedAt: '2026-08-02T10:00:00.000Z' }), + NOW, + ), + ).toBe(false); + }); + + it('is false for a one-off, which has no next meeting', () => { + expect(isOpenPastPeriod(meeting({ oneOff: true }), NOW)).toBe(false); + }); + + it('is false for a legacy meeting without a period', () => { + expect(isOpenPastPeriod(meeting({ periodicityDays: null }), NOW)).toBe( + false, + ); + }); + + it('is false for a period that is not positive', () => { + expect(isOpenPastPeriod(meeting({ periodicityDays: 0 }), NOW)).toBe(false); + }); +}); + +describe('nextCadenceDate', () => { + it('skips the cadence dates that are already past', () => { + // 2026-08-01 + 14 = 08-15 (past), + 28 = 08-29. + expect(nextCadenceDate('2026-08-01T00:00:00.000Z', 14, NOW)).toBe( + '2026-08-29', + ); + }); + + it('suggests today when today is on the cadence', () => { + expect(nextCadenceDate('2026-08-11T00:00:00.000Z', 14, NOW)).toBe( + '2026-08-25', + ); + expect(nextCadenceDate('2026-07-28T00:00:00.000Z', 14, NOW)).toBe( + '2026-08-25', + ); + }); + + it('moves to the following cadence date the day after', () => { + expect(nextCadenceDate('2026-08-10T00:00:00.000Z', 14, NOW)).toBe( + '2026-09-07', + ); + }); + + it('is one period after a meeting that is today or still ahead', () => { + expect(nextCadenceDate('2026-08-25T00:00:00.000Z', 7, NOW)).toBe( + '2026-09-01', + ); + expect(nextCadenceDate('2026-09-01T00:00:00.000Z', 7, NOW)).toBe( + '2026-09-08', + ); + }); + + it('covers a meeting left open for many periods', () => { + // 2026-01-06 is 231 days before NOW: 33 weekly periods, landing on today. + expect(nextCadenceDate('2026-01-06T00:00:00.000Z', 7, NOW)).toBe( + '2026-08-25', + ); + expect(nextCadenceDate('2026-01-07T00:00:00.000Z', 7, NOW)).toBe( + '2026-08-26', + ); + }); + + it('crosses a month and year boundary', () => { + expect( + nextCadenceDate( + '2026-12-20T00:00:00.000Z', + 30, + new Date('2027-01-25T12:00:00Z'), + ), + ).toBe('2027-02-18'); + }); +}); diff --git a/frontend/src/anketa/longOpen.ts b/frontend/src/anketa/longOpen.ts new file mode 100644 index 0000000..87a1571 --- /dev/null +++ b/frontend/src/anketa/longOpen.ts @@ -0,0 +1,46 @@ +/** + * A meeting left open for longer than its period, e.g. over a vacation + * (GitHub issue #204): the meeting list shows a banner for it, and its archive + * form suggests a next date that isn't in the past. + */ +import type { AnketaSummary } from '../api/types'; +import { daysUntilMeeting } from './isOverdue'; + +/** + * Whether the meeting is still open more than `periodicityDays` after its + * date, so the pair's next meeting on the same cadence is already due. Never + * a one-off, which has no next meeting to schedule, nor a legacy anketa + * without a period. + */ +export function isOpenPastPeriod( + anketa: Pick< + AnketaSummary, + 'archivedAt' | 'meetingDate' | 'periodicityDays' | 'oneOff' + >, + now: Date = new Date(), +): boolean { + if (anketa.archivedAt !== null || anketa.oneOff) return false; + if (anketa.periodicityDays === null || anketa.periodicityDays <= 0) { + return false; + } + return -daysUntilMeeting(anketa.meetingDate, now) > anketa.periodicityDays; +} + +/** + * The nearest date on the meeting's cadence (`meetingDate` plus a whole + * number of periods, at least one) that isn't before today, as `YYYY-MM-DD`. + * `periodicityDays` must be positive. "Today" is the viewer's local calendar + * date, as in daysUntilMeeting(). + */ +export function nextCadenceDate( + meetingDate: string, + periodicityDays: number, + now: Date = new Date(), +): string { + const daysPast = -daysUntilMeeting(meetingDate, now); + const periods = Math.max(1, Math.ceil(daysPast / periodicityDays)); + // A date-only string parses as UTC midnight, so UTC getters keep the day. + const next = new Date(meetingDate.slice(0, 10)); + next.setUTCDate(next.getUTCDate() + periods * periodicityDays); + return next.toISOString().slice(0, 10); +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index e5fccf3..f24b078 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -193,6 +193,7 @@ "badgeArchived": "archiviert", "badgeMissed": "verpasst", "badgeNotClosed": "nicht abgeschlossen", + "longOpenText": "{name}: Das 1:1 vom {date} ist noch nicht abgeschlossen.", "badgePublishedByMe": "von mir veröffentlicht", "badgePublishedByCounterpart": "vom Gegenüber veröffentlicht", "errorLoad": "1:1s konnten nicht geladen werden.", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 86cdedc..815fbf9 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -193,6 +193,7 @@ "badgeArchived": "archived", "badgeMissed": "missed", "badgeNotClosed": "not closed", + "longOpenText": "{name}: the 1:1 on {date} isn't closed yet.", "badgePublishedByMe": "published by me", "badgePublishedByCounterpart": "published by counterpart", "errorLoad": "Could not load 1:1s.", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 1b32f90..5b6a706 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -193,6 +193,7 @@ "badgeArchived": "archivada", "badgeMissed": "no realizada", "badgeNotClosed": "sin cerrar", + "longOpenText": "{name}: la reunión 1:1 del {date} aún no está cerrada.", "badgePublishedByMe": "publicado por mí", "badgePublishedByCounterpart": "publicado por el interlocutor", "errorLoad": "No se pudieron cargar las reuniones 1:1.", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index f2066b7..6c51a9f 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -193,6 +193,7 @@ "badgeArchived": "archivé", "badgeMissed": "manqué", "badgeNotClosed": "non clôturé", + "longOpenText": "{name} : le 1:1 du {date} n'est pas encore clôturé.", "badgePublishedByMe": "publié par moi", "badgePublishedByCounterpart": "publié par l'interlocuteur", "errorLoad": "Impossible de charger les 1:1.", diff --git a/frontend/src/i18n/locales/lv.json b/frontend/src/i18n/locales/lv.json index 0a4749e..3a4ad51 100644 --- a/frontend/src/i18n/locales/lv.json +++ b/frontend/src/i18n/locales/lv.json @@ -193,6 +193,7 @@ "badgeArchived": "arhivēta", "badgeMissed": "izlaista", "badgeNotClosed": "nav slēgta", + "longOpenText": "{name}: 1:1 tikšanās {date} vēl nav slēgta.", "badgePublishedByMe": "publicēju es", "badgePublishedByCounterpart": "publicēja sarunu biedrs", "errorLoad": "Neizdevās ielādēt 1:1 tikšanās.", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 51e7bf2..8ab0d74 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -193,6 +193,7 @@ "badgeArchived": "архивная", "badgeMissed": "пропущена", "badgeNotClosed": "не закрыта", + "longOpenText": "{name}: встреча 1:1 от {date} ещё не закрыта.", "badgePublishedByMe": "опубликовано мной", "badgePublishedByCounterpart": "опубликовано собеседником", "errorLoad": "Не удалось загрузить встречи 1:1.", diff --git a/frontend/src/pages/Anketa.svelte b/frontend/src/pages/Anketa.svelte index e8e6708..8cfd7ab 100644 --- a/frontend/src/pages/Anketa.svelte +++ b/frontend/src/pages/Anketa.svelte @@ -8,6 +8,7 @@ import AnketaHeader from '../anketa/AnketaHeader.svelte'; import { goToArchiveSection } from '../anketa/archiveHeading'; import { clearFollowUpHash, FOLLOW_UP_HASH } from '../anketa/followUpLinks'; + import { isOpenPastPeriod, nextCadenceDate } from '../anketa/longOpen'; import { clearJustCreated, isJustCreated, @@ -475,8 +476,18 @@ if (periodicityDays !== null) { // eslint-disable-next-line svelte/prefer-svelte-reactivity -- local scratch value, mutated once and read once, never stored in reactive state const defaultNext = new Date(); - defaultNext.setDate(defaultNext.getDate() + periodicityDays); - nextMeetingDate = defaultNext.toISOString().slice(0, 10); + if (isOpenPastPeriod(anketa, defaultNext)) { + // Left open for longer than its period (GitHub issue #204): back on + // the pair's cadence, not a full period from whenever it's closed. + nextMeetingDate = nextCadenceDate( + anketa.meetingDate, + periodicityDays, + defaultNext, + ); + } else { + defaultNext.setDate(defaultNext.getDate() + periodicityDays); + nextMeetingDate = defaultNext.toISOString().slice(0, 10); + } } nextTemplateDefault = defaultNextChoice(anketa); nextTemplateChoice = nextTemplateDefault; diff --git a/frontend/src/pages/AnketaList.svelte b/frontend/src/pages/AnketaList.svelte index 2ede001..15c2b79 100644 --- a/frontend/src/pages/AnketaList.svelte +++ b/frontend/src/pages/AnketaList.svelte @@ -16,6 +16,8 @@ } from '../crypto/anketaKey'; import { groupByCounterpart } from '../anketa/groupByCounterpart'; import { daysUntilMeeting } from '../anketa/isOverdue'; + import { FOLLOW_UP_HASH } from '../anketa/followUpLinks'; + import { isOpenPastPeriod } from '../anketa/longOpen'; import { extractTrendValues } from '../anketa/moodWorkloadTrend'; import TrendSparkline from '../anketa/TrendSparkline.svelte'; import { @@ -168,6 +170,20 @@ return { badges, daysLabel, templateLabel }; } + /** + * Meetings left open for longer than their period (GitHub issue #204), + * oldest first. Not one with a deleted counterpart: there's nobody to + * schedule the next one with. + */ + function longOpenMeetings(list: AnketaListRow[]): AnketaListRow[] { + const now = new Date(); + return list + .filter( + (anketa) => !anketa.counterpartDeleted && isOpenPastPeriod(anketa, now), + ) + .sort((a, b) => Date.parse(a.meetingDate) - Date.parse(b.meetingDate)); + } + // Cancels this page's own passive read fetches (this one and // loadTrendData's below) on unmount — see abortOnDestroy's own docblock // for why reshareAll below is deliberately excluded. @@ -379,6 +395,31 @@ {/if} + {@const longOpen = longOpenMeetings(list)} + {#if longOpen.length > 0} +
    + {#each longOpen as anketa (anketa.id)} +
  • + {$_('anketaList.longOpenText', { + values: { + name: fullDisplayName( + anketa.counterpartName, + anketa.counterpartEmail, + ), + date: formatDisplayDate(anketa.meetingDate), + }, + })} + + {$_('anketa.closeAndScheduleNext')} +
  • + {/each} +
+ {/if} {#if list.length === 0}
    @@ -533,6 +574,20 @@ margin-bottom: 20px; } + .long-open { + list-style: none; + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 20px; + } + + .long-open li { + display: flex; + flex-wrap: wrap; + column-gap: 10px; + } + .view-toggle { margin-bottom: 20px; }