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
25 changes: 21 additions & 4 deletions .claude/skills/transifex/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,29 @@ const label = t('attendance', 'copy')
<span>{{ t('attendance', 'Required') }}</span>
```

Rules:

- The comment binds only to the **first** translation call on the
following line — one call per line, each with its own comment.
Rules (empirically verified against the extractor, not just the docs):

- The comment binds **only to the first `t()` call on the immediately
following line**. A `t()` two lines down — multi-line ternary,
multi-line attribute binding, element tag line without the
translated attribute — silently gets nothing. Restructure instead:
ternaries become if/else or a computed/function with one comment per
`return t(…)` line; template attribute bindings either go on the tag
line directly after the comment or move into a computed.
- The second `t()` of a one-line ternary also gets nothing — one call
per line, each with its own comment.
- In Vue `<template>` blocks use the HTML comment form, in `<script>`
the `//` form.
- **Verify before committing** — generate the .pot locally and grep for
the hint text:

```bash
docker run --rm --platform linux/amd64 --entrypoint php -v "$PWD:/app" -w /app ghcr.io/nextcloud/continuous-integration-translations:latest /translationtool.phar create-pot-files
grep -c "<distinctive hint words>" translationfiles/templates/attendance.pot
```

`translationfiles/` is generated — delete it afterwards, never commit
it.
- Word the hint as what a translator needs (where the string appears,
noun vs. verb, what placeholders contain) — essentially a condensed
version of the reply you are about to post.
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/transifex/state.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"answered_up_to": "2026-07-24T17:53:15Z"
"answered_up_to": "2026-07-24T22:22:57Z"
}
1 change: 1 addition & 0 deletions lib/Service/IcalService.php
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ private function generateVEvent(
if ($appointment->isCancelled()) {
$status = 'CANCELLED';
$transp = 'TRANSPARENT';
// TRANSLATORS Prefix on the calendar event title of an appointment that was called off (German "Abgesagt", not "Abgebrochen").
$summary = $l->t('Cancelled') . ': ' . $summary;
}

Expand Down
2 changes: 2 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@ t('attendance', 'App developer')
t('attendance', 'Ask each time')
t('attendance', 'Attendees scan this code with the Attendance app to check in. One code works for all appointments.')
t('attendance', 'Bring it to your team')
// TRANSLATORS: Shown on an appointment that was called off; {when} is the date of the cancellation (German "Abgesagt am {when}", not "Abgebrochen").
t('attendance', 'Cancelled on {when}')
t('attendance', 'Check in')
t('attendance', 'Check-in failed')
Expand Down Expand Up @@ -456,6 +457,7 @@ t('attendance', 'Tag written. Scan it to test the check-in.')
t('attendance', 'Tell me more (optional)')
t('attendance', 'Thanks — that really helps.')
t('attendance', 'There is no appointment to check into right now.')
// TRANSLATORS: The appointment was called off and will not take place (German "abgesagt", not "abgebrochen").
t('attendance', 'This appointment has been cancelled.')
t('attendance', 'This code belongs to {host}.')
t('attendance', 'This is not an Attendance check-in tag.')
Expand Down
70 changes: 37 additions & 33 deletions src/components/appointment/AppointmentCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,8 @@
<CalendarSyncIcon :size="14" />
</a>
</span>
<NcChip
v-if="isCancelled"
class="cancelled-badge"
:text="t('attendance', 'Cancelled')"
variant="error"
noClose
data-test="cancelled-badge" />
<!-- TRANSLATORS: Status badge on an appointment that was called off (German "Abgesagt", not "Abgebrochen"). The generic dialog button "Cancel" (German "Abbrechen") is a different string. -->
<NcChip v-if="isCancelled" class="cancelled-badge" :text="t('attendance', 'Cancelled')" variant="error" noClose data-test="cancelled-badge" />
</div>
<div class="appointment-actions">
<NcActions
Expand Down Expand Up @@ -113,11 +108,7 @@
<CalendarRefreshIcon v-if="isCancelled" :size="20" />
<CalendarRemoveIcon v-else :size="20" />
</template>
{{
isCancelled
? t("attendance", "Reactivate appointment")
: t("attendance", "Cancel appointment")
}}
{{ cancelToggleLabel }}
</NcActionButton>
<NcActionButton
v-if="canManageAppointments"
Expand Down Expand Up @@ -337,40 +328,25 @@
data-test="checkin-summary">
<h4>{{ t("attendance", "Check-in summary") }}</h4>
<div class="summary-stats">
<!-- TRANSLATORS: Check-in status chip on the appointment card — the person has checked in. Sibling chips: "{count} absent", "{count} pending". All three describe the current check-in state of the same event, so translate as status labels rather than strict past/present tense. -->
<NcChip
:text="
t('attendance', '{count} attended', {
count: appointment.checkinSummary.attended,
})
"
:text="attendedChipText"
variant="success"
noClose>
<template #icon>
<CheckIcon :size="16" />
</template>
</NcChip>
<!-- TRANSLATORS: Check-in status chip — the person was explicitly marked absent. See "{count} attended". -->
<NcChip
:text="
t('attendance', '{count} absent', {
count: appointment.checkinSummary.absent,
})
"
:text="absentChipText"
variant="error"
noClose>
<template #icon>
<CloseIcon :size="16" />
</template>
</NcChip>
<!-- TRANSLATORS: Check-in status chip — no check-in recorded for the person yet. See "{count} attended". -->
<NcChip
v-if="appointment.checkinSummary.notCheckedIn > 0"
:text="
t('attendance', '{count} pending', {
count: appointment.checkinSummary.notCheckedIn,
})
"
:text="pendingChipText"
variant="tertiary"
noClose>
<template #icon>
Expand Down Expand Up @@ -606,6 +582,30 @@ const canToggleClosed = computed(() => {

const isCancelled = computed(() => Boolean(props.appointment.cancelledAt))

// Lives here instead of inline in the template: the string extractor only
// associates a TRANSLATORS comment with the first t() call on the very
// next line, which a multi-line ternary cannot provide.
const cancelToggleLabel = computed(() => {
if (isCancelled.value) {
// TRANSLATORS: Menu action that takes a cancellation back — the appointment will take place again.
return t('attendance', 'Reactivate appointment')
}
// TRANSLATORS: Menu action that calls off the appointment — it will not take place (German "Termin absagen", not "abbrechen").
return t('attendance', 'Cancel appointment')
})

// Check-in status chips of the same event — like above, each t() sits
// directly below its TRANSLATORS comment for the string extractor.
const attendedChipText = computed(() =>
// TRANSLATORS: Check-in status chip on the appointment card — the person has checked in. Sibling chips: "{count} absent", "{count} pending". All three describe the current check-in state of the same event, so translate as status labels rather than strict past/present tense.
t('attendance', '{count} attended', { count: props.appointment.checkinSummary?.attended ?? 0 }))
const absentChipText = computed(() =>
// TRANSLATORS: Check-in status chip — the person was explicitly marked absent. See "{count} attended".
t('attendance', '{count} absent', { count: props.appointment.checkinSummary?.absent ?? 0 }))
const pendingChipText = computed(() =>
// TRANSLATORS: Check-in status chip — no check-in recorded for the person yet. See "{count} attended".
t('attendance', '{count} pending', { count: props.appointment.checkinSummary?.notCheckedIn ?? 0 }))

// Cancelling is a manager/creator action gated behind the server capability, so
// instances (and older servers) that don't offer it never show the UI.
const canCancel = computed(() => capabilities.cancelling && canToggleClosed.value)
Expand Down Expand Up @@ -832,9 +832,13 @@ async function handleToggleCancelled() {
const url = generateUrl(`/apps/attendance/api/appointments/${props.appointment.id}/${wantsCancel ? 'cancel' : 'uncancel'}`)
try {
const response = await axios.post(url)
showSuccess(wantsCancel
? t('attendance', 'Appointment cancelled')
: t('attendance', 'Appointment reactivated'))
if (wantsCancel) {
// TRANSLATORS: Success toast — the appointment was called off (German "abgesagt", not "abgebrochen").
showSuccess(t('attendance', 'Appointment cancelled'))
} else {
// TRANSLATORS: Success toast — the cancellation was taken back, the appointment takes place again.
showSuccess(t('attendance', 'Appointment reactivated'))
}
// Reuse the closedToggled channel: the parent merges the full updated
// appointment (incl. cancelledAt) reactively, so no extra wiring needed.
emit('closedToggled', response.data)
Expand Down
39 changes: 27 additions & 12 deletions src/components/appointment/ResponseSummary.vue
Original file line number Diff line number Diff line change
Expand Up @@ -126,20 +126,18 @@
</NcButton>
</div>
</NcPopover>
<!-- TRANSLATORS: Tooltip on the disabled scheduling toggle. "scheduling" = the feature of giving people a place in the appointment (German: the noun is "Planung", the per-person action is "einplanen"). -->
<NcButton
v-if="canManageBooking && response.response === 'yes'"
class="booking-toggle"
:variant="response.bookingStatus === 'booked' ? 'success' : 'tertiary'"
:disabled="togglingBooking.has(response.userId) || isClosed"
:title="isClosed ? t('attendance', 'Reopen the inquiry to change scheduling') : null"
:title="bookingToggleTitle"
:data-test="`booking-toggle-${response.userId}`"
@click="toggleBooking(response)">
<template #icon>
<CalendarCheckIcon :size="20" />
</template>
<!-- TRANSLATORS: Toggle button per person. "Scheduled" = status label, the person got a place in the appointment (German "Eingeplant"). "Schedule" = verb/action, the manager gives the person a place ("schedule someone in", German "Einplanen" — not "Planen": the appointment itself is not being planned). -->
{{ response.bookingStatus === 'booked' ? t('attendance', 'Scheduled') : t('attendance', 'Schedule') }}
{{ bookingToggleLabel(response) }}
</NcButton>
</div>
<div
Expand Down Expand Up @@ -285,20 +283,18 @@
</NcButton>
</div>
</NcPopover>
<!-- TRANSLATORS: Tooltip on the disabled scheduling toggle. "scheduling" = the feature of giving people a place in the appointment (German: the noun is "Planung", the per-person action is "einplanen"). -->
<NcButton
v-if="canManageBooking && response.response === 'yes'"
class="booking-toggle"
:variant="response.bookingStatus === 'booked' ? 'success' : 'tertiary'"
:disabled="togglingBooking.has(response.userId) || isClosed"
:title="isClosed ? t('attendance', 'Reopen the inquiry to change scheduling') : null"
:title="bookingToggleTitle"
:data-test="`booking-toggle-${response.userId}`"
@click="toggleBooking(response)">
<template #icon>
<CalendarCheckIcon :size="20" />
</template>
<!-- TRANSLATORS: Toggle button per person. "Scheduled" = status label, the person got a place in the appointment (German "Eingeplant"). "Schedule" = verb/action, the manager gives the person a place ("schedule someone in", German "Einplanen" — not "Planen": the appointment itself is not being planned). -->
{{ response.bookingStatus === 'booked' ? t('attendance', 'Scheduled') : t('attendance', 'Schedule') }}
{{ bookingToggleLabel(response) }}
</NcButton>
</div>
<div
Expand Down Expand Up @@ -434,20 +430,18 @@
</NcButton>
</div>
</NcPopover>
<!-- TRANSLATORS: Tooltip on the disabled scheduling toggle. "scheduling" = the feature of giving people a place in the appointment (German: the noun is "Planung", the per-person action is "einplanen"). -->
<NcButton
v-if="canManageBooking && response.response === 'yes'"
class="booking-toggle"
:variant="response.bookingStatus === 'booked' ? 'success' : 'tertiary'"
:disabled="togglingBooking.has(response.userId) || isClosed"
:title="isClosed ? t('attendance', 'Reopen the inquiry to change scheduling') : null"
:title="bookingToggleTitle"
:data-test="`booking-toggle-${response.userId}`"
@click="toggleBooking(response)">
<template #icon>
<CalendarCheckIcon :size="20" />
</template>
<!-- TRANSLATORS: Toggle button per person. "Scheduled" = status label, the person got a place in the appointment (German "Eingeplant"). "Schedule" = verb/action, the manager gives the person a place ("schedule someone in", German "Einplanen" — not "Planen": the appointment itself is not being planned). -->
{{ response.bookingStatus === 'booked' ? t('attendance', 'Scheduled') : t('attendance', 'Schedule') }}
{{ bookingToggleLabel(response) }}
</NcButton>
</div>
<div
Expand Down Expand Up @@ -552,6 +546,27 @@ const canManageBooking = computed(() => props.canManageAppointments && props.boo
const expandedGroups = ref({})
const remindingUsers = reactive(new Set())
const togglingBooking = reactive(new Set())

// Booking-toggle texts live here instead of inline in the template: the
// string extractor only associates a TRANSLATORS comment with the first
// t() call on the very next line, which multi-line template expressions
// cannot provide.
const bookingToggleTitle = computed(() => {
if (!props.isClosed) {
return null
}
// TRANSLATORS: Tooltip on the disabled scheduling toggle. "scheduling" = the feature of giving people a place in the appointment (German: the noun is "Planung", the per-person action is "einplanen").
return t('attendance', 'Reopen the inquiry to change scheduling')
})

function bookingToggleLabel(response) {
if (response.bookingStatus === 'booked') {
// TRANSLATORS: Status label on the per-person scheduling toggle — the person got a place in the appointment (German "Eingeplant", not "Geplant").
return t('attendance', 'Scheduled')
}
// TRANSLATORS: Action label on the per-person scheduling toggle — the manager gives the person a place in the appointment ("schedule someone in", German "Einplanen" — not "Planen": the appointment itself is not being planned).
return t('attendance', 'Schedule')
}
const openRemindPopover = ref(null)

async function toggleBooking(response) {
Expand Down
3 changes: 1 addition & 2 deletions src/views/AdminSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@
</NcSettingsSection>

<!-- TRANSLATORS: Admin settings section title. Similar to groups above, but for Nextcloud Teams (formerly Circles). Teams selected here will have their own sections in the attendance statistics on the appointment detail page, showing how many team members responded yes/no/maybe. -->
<NcSettingsSection v-if="teamsAvailable"
:name="t('attendance', 'Response summary teams')"
<NcSettingsSection v-if="teamsAvailable" :name="t('attendance', 'Response summary teams')"
:description="t('attendance', 'Select which teams to include in response summaries. Team members will be grouped together like regular groups.')"
data-test="section-tracking-teams">
<NcSelect
Expand Down
2 changes: 2 additions & 0 deletions src/views/AllAppointments.vue
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ const filterDefs = computed(() => [
options: [
{ id: STATUS.OPEN, label: t('attendance', 'Opened') },
{ id: STATUS.CLOSED, label: t('attendance', 'Closed') },
// TRANSLATORS: Filter option — appointments that were called off (German "Abgesagt", not "Abgebrochen").
{ id: STATUS.CANCELLED, label: t('attendance', 'Cancelled') },
],
},
Expand Down Expand Up @@ -358,6 +359,7 @@ const visibleSections = computed(() => {
return [
upcoming.length && { key: 'upcoming', label: t('attendance', 'Upcoming'), items: upcoming },
past.length && { key: 'past', label: t('attendance', 'Past'), items: past },
// TRANSLATORS: Group heading for appointments that were called off (German "Abgesagt", not "Abgebrochen").
cancelled.length && { key: 'cancelled', label: t('attendance', 'Cancelled'), items: cancelled },
].filter(Boolean)
})
Expand Down
Loading