Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/user-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 34 additions & 0 deletions frontend/e2e/dual-actor-anketa.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`,
);
});
132 changes: 132 additions & 0 deletions frontend/src/anketa/longOpen.test.ts
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof isOpenPastPeriod>[0]> = {},
): Parameters<typeof isOpenPastPeriod>[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');
});
});
46 changes: 46 additions & 0 deletions frontend/src/anketa/longOpen.ts
Original file line number Diff line number Diff line change
@@ -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);
}
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/lv.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading
Loading