Calendar and contacts web UI, part 3: QA fixes, week start setting

- Week start is a profile setting (Monday, Sunday, Saturday; default
  Monday), stored in users.week_start (schema v13), no longer taken from
  the language
- PUT and import reject a VEVENT without DTSTART and a VTODO with
  DURATION but no DTSTART (valid-calendar-data)
- Import skips name the UID and a readable reason
- Month view: declined instances struck through, "+N more" on one line,
  collection list below the grid under 1180px and after it on phones
- Contact card: localized TYPE labels; initials skip words without a
  leading letter
- Dialogs: textareas and anchor buttons styled like their neighbours, a
  lent row stays on one line on phones, no detail level for address book
  feeds, stable accessible names for visibility toggles
- Links use the accent color, readable in dark mode

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commit4487d210e37e5e8f0feafb60468a82887480f2ba
Parentd3f65c4
20 files changed, 307 insertions(+), 47 deletions(-)
▾MREADME.md
@@ -287,8 +287,9 @@ feed on the Shares page.
The Calendar page has three views:
- **Month**: six weeks, starting on Sunday in English and on Monday in
German and French. Events that last a day or more are bars across the
- **Month**: six weeks, starting on the day set under Settings → Profile
→ "Week starts on" (Monday, Sunday or Saturday; Monday by default).
Events that last a day or more are bars across the
days, timed events show their start. A day with more events than fit
shows "+N more". A click on a day lists its events below the grid. On a
phone the days show colored dots instead, and the list below the grid
▾Mapi-types/src/lib.rs
@@ -445,6 +445,9 @@ pub struct UserInfo {
/// Preferred UI language tag ("en", "de", "fr"); None = follow the
/// browser.
pub language: Option<String>,
/// Profile setting: the first day of the week in the calendar, 0 for
/// Sunday through 6 for Saturday.
pub week_start: u8,
/// Profile setting: the root the UI opens on page load and on the home
/// link. Always one of `Me::roots` (the server drops a stale id), or
/// None for the root picker.
@@ -1026,6 +1029,7 @@ mod tests {
single_click_open: false,
thumbnails: true,
language: None,
week_start: 1,
default_root_id: None,
auth_mode: AuthMode::Either,
has_password: true,
▾Mpimdav/README.md
@@ -168,6 +168,11 @@ rule with BYMONTH, that is `1TU` to `5TU`. Otherwise it is `1TU` to `53TU`.
(`object::with_dtstamp`). The line is inserted after its BEGIN line, so
every other byte stays. RFC 5545 requires DTSTAMP, and iTIP uses it to
order messages. The PUT then returns no ETag.
- A VEVENT without DTSTART, or a VTODO with DURATION but no DTSTART, fails
`valid-calendar-data`, as RFC 5545 requires. Import skips such objects.
Not checked: DTEND together with DURATION, DUE together with DURATION,
and a VTIMEZONE for every TZID. Clients get these wrong often enough
that rejecting them would lose data.
### Text matching
▾Mpimdav/src/object.rs
@@ -10,7 +10,7 @@ use crate::xml::{CALDAV, CARDDAV, el};
/// Why a PUT body is refused, as the precondition the RFCs name.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Invalid {
/// Not parseable as iCalendar.
/// Not parseable as iCalendar, or missing a property RFC 5545 requires.
CalendarData,
/// Parseable, but not one CalDAV object: several UIDs, mixed component
/// types, a METHOD, or no component at all.
@@ -71,6 +71,16 @@ pub fn calendar(body: &[u8], supported: &[&str]) -> Result<CalendarObject, Inval
ICalendarComponentType::VJournal => "VJOURNAL",
_ => return Err(Invalid::CalendarComponent),
};
// RFC 5545 requires DTSTART on a VEVENT without METHOD, and on a
// VTODO with DURATION.
let needs_start = match component {
"VEVENT" => true,
"VTODO" => c.has_property(&ICalendarProperty::Duration),
_ => false,
};
if needs_start && !c.has_property(&ICalendarProperty::Dtstart) {
return Err(Invalid::CalendarData);
}
let uid = c.uid().ok_or(Invalid::CalendarResource)?;
match &found {
Some(f) if f.uid != uid || f.component != component => {
▾Mpimdav/tests/protocol.rs
@@ -110,6 +110,8 @@ fn calendar_objects() {
);
let override_ = EVENT.replace("DTSTART", "RECURRENCE-ID:20260102T100000Z\r\nDTSTART");
assert!(object::calendar(&ics(&format!("{EVENT}{override_}")), &all).is_ok());
let todo = "BEGIN:VTODO\r\nUID:t\r\nDTSTAMP:20260101T000000Z\r\nEND:VTODO\r\n";
assert!(object::calendar(&ics(todo), &all).is_ok());
let cases = [
(
@@ -133,6 +135,14 @@ fn calendar_objects() {
ics(&EVENT.replace("VEVENT", "VJOURNAL")),
Invalid::CalendarComponent,
),
(
ics(&EVENT.replace("DTSTART:20260101T100000Z\r\n", "")),
Invalid::CalendarData,
),
(
ics("BEGIN:VTODO\r\nUID:t\r\nDURATION:PT1H\r\nEND:VTODO\r\n"),
Invalid::CalendarData,
),
(b"not a calendar".to_vec(), Invalid::CalendarData),
(vec![0xff, 0xfe], Invalid::CalendarData),
([ics(EVENT), ics(EVENT)].concat(), Invalid::CalendarResource),
▾Mserver/src/api/auth.rs
@@ -60,6 +60,7 @@ async fn me_for(state: &AppState, user: &User, roots: Vec<RootRow>) -> Result<Me
single_click_open: user.single_click,
thumbnails: user.thumbnails,
language: user.language.clone(),
week_start: user.week_start,
// A removed root leaves a stale id behind; the client never
// sees it.
default_root_id: user
@@ -87,6 +88,7 @@ pub(crate) struct ProfilePatch {
pub thumbnails: Option<bool>,
#[serde(default, deserialize_with = "patch_field")]
pub language: Option<Option<String>>,
pub week_start: Option<u8>,
/// `null` clears the default root (back to the root picker).
#[serde(default, deserialize_with = "patch_field")]
pub default_root_id: Option<Option<i64>>,
@@ -130,6 +132,14 @@ pub async fn update_profile(
"err_invalid_language",
));
}
// The UI offers these three: common enough, and all a month grid needs.
if body.week_start.is_some_and(|d| ![0, 1, 6].contains(&d)) {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid week start",
"err_invalid_week_start",
));
}
if let Some(Some(id)) = body.default_root_id
&& !roots.iter().any(|r| r.id == id)
{
@@ -148,6 +158,9 @@ pub async fn update_profile(
if let Some(lang) = body.language {
user.language = lang;
}
if let Some(d) = body.week_start {
user.week_start = d;
}
if let Some(root_id) = body.default_root_id {
user.default_root_id = root_id;
}
▾Mserver/src/api/pim_api.rs
@@ -784,7 +784,12 @@ pub async fn import(
let (uid, component) = match checked {
Ok(v) => v,
Err(invalid) => {
skip(None, &invalid.condition().name);
// Read from the raw text: the object did not parse as a whole.
let uid = part
.lines()
.find_map(|l| l.strip_prefix("UID:"))
.map(|u| u.trim().to_string());
skip(uid, &invalid.condition().name);
continue;
}
};
▾Mserver/src/db.rs
@@ -7,7 +7,7 @@ use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ValueRef};
use rusqlite::{Connection, OptionalExtension, params};
use webauthn_rs::prelude::Uuid;
const SCHEMA_VERSION: i64 = 12;
const SCHEMA_VERSION: i64 = 13;
/// SQL adapter for reading a [`Mode`]. A newtype is needed because both the
/// rusqlite traits and `Mode` are foreign to this crate. Writes bind
@@ -63,6 +63,9 @@ pub struct User {
/// Preferred UI language tag ("en", "de", "fr"); None = follow the
/// browser.
pub language: Option<String>,
/// Profile setting: the first day of the week in the month grid, 0 for
/// Sunday through 6 for Saturday.
pub week_start: u8,
/// Profile setting: the root the UI opens by default. May point at a
/// root the user no longer has; the API filters that out.
pub default_root_id: Option<i64>,
@@ -348,12 +351,13 @@ impl Precondition {
/// `USER_COLS_U` is the same list qualified for the queries that join
/// `users u` against `user_roots`.
const USER_COLS: &str = "id, name, is_admin != 0, active != 0, single_click != 0,
thumbnails != 0, language, default_root_id, auth_mode, pass_hash != ''";
thumbnails != 0, language, default_root_id, auth_mode, pass_hash != '', week_start";
const USER_COLS_U: &str = "u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0,
u.thumbnails != 0, u.language, u.default_root_id, u.auth_mode, u.pass_hash != ''";
u.thumbnails != 0, u.language, u.default_root_id, u.auth_mode, u.pass_hash != '',
u.week_start";
/// How many columns the two lists above cover. The joined queries read the
/// root columns starting here.
const USER_COL_COUNT: usize = 10;
const USER_COL_COUNT: usize = 11;
/// Every query can fail, and every caller decides what to do about it.
///
@@ -605,6 +609,12 @@ impl Db {
);",
)?;
}
if version < 13 {
// Monday, the ISO 8601 week start.
conn.execute_batch(
"ALTER TABLE users ADD COLUMN week_start INTEGER NOT NULL DEFAULT 1",
)?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
@@ -829,13 +839,14 @@ impl Db {
let c = self.0.lock().await;
c.execute(
"UPDATE users SET single_click = ?1, thumbnails = ?2, language = ?3,
default_root_id = ?4
WHERE id = ?5",
default_root_id = ?4, week_start = ?5
WHERE id = ?6",
params![
u.single_click,
u.thumbnails,
u.language,
u.default_root_id,
u.week_start,
u.id
],
)?;
@@ -2172,6 +2183,7 @@ fn map_user(r: &rusqlite::Row) -> DbResult<User> {
default_root_id: r.get(7)?,
auth_mode: r.get::<_, SqlAuthMode>(8)?.0,
has_password: r.get(9)?,
week_start: r.get(10)?,
})
}
@@ -2197,6 +2209,7 @@ fn new_user(id: i64, name: &str, is_admin: bool) -> User {
single_click: false,
thumbnails: true,
language: None,
week_start: 1,
default_root_id: None,
auth_mode: AuthMode::Either,
has_password: true,
▾Mserver/tests/api_auth.rs
@@ -296,3 +296,25 @@ async fn default_root_is_validated_and_dropped_with_its_root() {
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.json()["user"]["default_root_id"], json!(null));
}
#[tokio::test]
async fn week_start_defaults_to_monday_and_is_validated() {
let env = Env::new().await;
let admin = env.admin().await;
assert_eq!(
admin.get("/api/auth/me").await.json()["user"]["week_start"],
json!(1)
);
let r = admin
.put_json("/api/auth/me", &json!({ "week_start": 3 }))
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text());
let r = admin
.put_json("/api/auth/me", &json!({ "week_start": 6 }))
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(
admin.get("/api/auth/me").await.json()["user"]["week_start"],
json!(6)
);
}
▾Mserver/tests/api_pim_io.rs
@@ -245,12 +245,20 @@ async fn import_splits_and_updates() {
BEGIN:VEVENT\r\nUID:m\r\nDTSTART:20260105T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID:20260106T100000Z\r\nDTSTART:20260106T120000Z\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nDTSTART:20260107T100000Z\r\nSUMMARY:no uid\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:nostart\r\nSUMMARY:no start\r\nEND:VEVENT\r\n\
BEGIN:VFREEBUSY\r\nUID:fb\r\nEND:VFREEBUSY\r\nEND:VCALENDAR\r\n";
let r = io.import(cal, file).await;
assert_eq!(r["created"], 2, "{r}");
assert_eq!(r["updated"], 0);
assert_eq!(r["skipped_total"], 1);
assert_eq!(r["skipped"][0]["reason"], "supported-calendar-component");
assert_eq!(r["skipped_total"], 2);
let reasons: Vec<&str> = r["skipped"]
.as_array()
.unwrap()
.iter()
.map(|s| s["reason"].as_str().unwrap())
.collect();
assert!(reasons.contains(&"supported-calendar-component"), "{r}");
assert!(reasons.contains(&"valid-calendar-data"), "{r}");
// The same file again updates, also the event that had no UID.
let r = io.import(cal, file).await;
▾Mweb/app.css
@@ -140,11 +140,23 @@ input {
font-size: 14px;
}
input:focus {
input:focus,
textarea:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
}
textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 14px;
resize: vertical;
}
/* Square checkboxes — native controls are rounded, the theme is sharp. */
input[type="checkbox"] {
appearance: none;
@@ -178,7 +190,8 @@ input[type="checkbox"]:checked::after {
transform: rotate(45deg);
}
button {
button,
:where(a.btn) {
font: inherit;
cursor: pointer;
display: inline-flex;
@@ -191,7 +204,18 @@ button {
padding: 9px 14px;
}
button:hover {
/* Browser link colors are unreadable on the dark panels. */
a {
color: var(--accent);
}
a.btn {
color: var(--text);
text-decoration: none;
}
button:hover,
:where(a.btn):hover {
border-color: var(--muted);
}
@@ -3188,7 +3212,8 @@ body:has(.page-busy) {
margin-top: 28px;
}
@media (max-width: 860px) {
/* Below this the month grid needs the width of the collection list. */
@media (max-width: 1180px) {
.pim-view {
flex-direction: column;
}
@@ -3197,8 +3222,11 @@ body:has(.page-busy) {
width: auto;
}
/* The events and contacts come first; the list of collections would
fill a whole phone screen above them. */
.pim-main {
min-height: 240px;
order: -1;
}
}
@@ -3216,9 +3244,15 @@ body:has(.page-busy) {
flex-wrap: wrap;
}
.pim-mode {
.pim-add .pim-mode {
max-width: 100%;
}
/* A lent row keeps name, mode and remove button on one line. */
.pim-item .pim-mode {
flex: 0 1 auto;
min-width: 0;
}
}
/* Mirrored chevron: the icon set has only the right-pointing one. */
@@ -3489,6 +3523,9 @@ body:has(.page-busy) {
color: var(--muted);
font-size: 12px;
line-height: 20px;
white-space: nowrap;
overflow: hidden;
min-width: 0;
}
.cal-more:hover {
▾Mweb/src/api.rs
@@ -142,6 +142,8 @@ struct ProfilePatch {
language: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
default_root_id: Option<Option<i64>>,
#[serde(skip_serializing_if = "Option::is_none")]
week_start: Option<u8>,
}
pub fn update_profile(
@@ -149,6 +151,7 @@ pub fn update_profile(
thumbnails: Option<bool>,
language: Option<Option<String>>,
default_root_id: Option<Option<i64>>,
week_start: Option<u8>,
) -> impl std::future::Future<Output = Result<Me, ApiError>> {
request(
"PUT",
@@ -158,6 +161,7 @@ pub fn update_profile(
thumbnails,
language,
default_root_id,
week_start,
}),
)
}
▾Mweb/src/i18n.rs
@@ -223,6 +223,11 @@ i18n_keys! {
CREATE_LINK = "create_link" => "Create link",
CREATE_USER = "create_user" => "Create user",
CREATING = "creating" => "Creating…",
CT_TYPE_CELL = "ct_type_cell" => "mobile",
CT_TYPE_FAX = "ct_type_fax" => "fax",
CT_TYPE_HOME = "ct_type_home" => "home",
CT_TYPE_OTHER = "ct_type_other" => "other",
CT_TYPE_WORK = "ct_type_work" => "work",
CUSTOM = "custom" => "Custom…",
DEFAULT_ROOT_DESC = "default_root_desc" => "Open this folder on page load and on the home link.",
DEFAULT_ROOT_LABEL = "default_root_label" => "Default folder",
@@ -282,6 +287,7 @@ i18n_keys! {
ERR_INVALID_CREDENTIALS = "err_invalid_credentials" => "invalid name or password",
ERR_INVALID_DEFAULT_ROOT = "err_invalid_default_root" => "the default folder is not one of your folders",
ERR_INVALID_LANGUAGE = "err_invalid_language" => "invalid language tag",
ERR_INVALID_WEEK_START = "err_invalid_week_start" => "invalid week start",
ERR_LAST_ADMIN = "err_last_admin" => "cannot remove the last active admin",
ERR_LAST_ADMIN_DELETE = "err_last_admin_delete" => "cannot delete the last active admin",
ERR_LOCKED_OUT = "err_locked_out" => "That would leave the account with no way to sign in.",
@@ -572,6 +578,10 @@ i18n_keys! {
PIM_SHARING = "pim_sharing" => "Sharing",
PIM_SHARING_HINT = "pim_sharing_hint" => "Share with another account on this server.",
PIM_SHOW = "pim_show" => "Show",
PIM_SKIP_COMPONENT = "pim_skip_component" => "this type is not accepted here",
PIM_SKIP_INVALID = "pim_skip_invalid" => "invalid or incomplete data",
PIM_SKIP_SIZE = "pim_skip_size" => "too large",
PIM_SKIP_UID = "pim_skip_uid" => "the same UID is already in another calendar",
PIM_SUBSCRIBE = "pim_subscribe" => "Subscribe",
PIM_TENTATIVE = "pim_tentative" => "Maybe",
PIM_THIS_EVENT = "pim_this_event" => "Only this event",
@@ -734,6 +744,11 @@ i18n_keys! {
USER_UPDATED = "user_updated" => "User updated",
USER_YOU = "user_you" => "(you)",
USERS = "users" => "Users",
WEEK_MONDAY = "week_monday" => "Monday",
WEEK_SATURDAY = "week_saturday" => "Saturday",
WEEK_START = "week_start" => "Week starts on",
WEEK_START_DESC = "week_start_desc" => "The first day of the week in the calendar.",
WEEK_SUNDAY = "week_sunday" => "Sunday",
YOUR_SHARES = "your_shares" => "Your shares",
}
@@ -785,6 +800,11 @@ const DE: &[(&str, &str)] = &[
("create_link", "Link erstellen"),
("create_user", "Benutzer erstellen"),
("creating", "Wird erstellt…"),
("ct_type_cell", "mobil"),
("ct_type_fax", "Fax"),
("ct_type_home", "privat"),
("ct_type_other", "andere"),
("ct_type_work", "geschäftlich"),
("custom", "Benutzerdefiniert…"),
(
"default_root_desc",
@@ -904,6 +924,7 @@ const DE: &[(&str, &str)] = &[
"Der Standardordner gehört nicht zu Ihren Ordnern",
),
(k::ERR_INVALID_LANGUAGE, "ungültiges Sprachkürzel"),
(k::ERR_INVALID_WEEK_START, "ungültiger Wochenbeginn"),
(
k::ERR_LAST_ADMIN,
"der letzte aktive Administrator kann nicht entfernt werden",
@@ -1392,6 +1413,16 @@ const DE: &[(&str, &str)] = &[
"Mit einem anderen Konto auf diesem Server teilen.",
),
("pim_show", "Einblenden"),
(
"pim_skip_component",
"dieser Typ wird hier nicht angenommen",
),
("pim_skip_invalid", "ungültige oder unvollständige Daten"),
("pim_skip_size", "zu groß"),
(
"pim_skip_uid",
"dieselbe UID liegt schon in einem anderen Kalender",
),
("pim_subscribe", "Abonnieren"),
("pim_tentative", "Vielleicht"),
("pim_this_event", "Nur dieser Termin"),
@@ -1647,6 +1678,11 @@ const DE: &[(&str, &str)] = &[
("user_updated", "Benutzer aktualisiert"),
("user_you", "(Sie)"),
("users", "Benutzer"),
("week_monday", "Montag"),
("week_saturday", "Samstag"),
("week_start", "Woche beginnt am"),
("week_start_desc", "Der erste Wochentag im Kalender."),
("week_sunday", "Sonntag"),
("your_shares", "Ihre Freigaben"),
];
@@ -1704,6 +1740,11 @@ const FR: &[(&str, &str)] = &[
("create_link", "Créer le lien"),
("create_user", "Créer l'utilisateur"),
("creating", "Création…"),
("ct_type_cell", "portable"),
("ct_type_fax", "fax"),
("ct_type_home", "domicile"),
("ct_type_other", "autre"),
("ct_type_work", "travail"),
("custom", "Personnalisé…"),
(
"default_root_desc",
@@ -1820,6 +1861,7 @@ const FR: &[(&str, &str)] = &[
"le dossier par défaut ne fait pas partie de vos dossiers",
),
(k::ERR_INVALID_LANGUAGE, "balise de langue invalide"),
(k::ERR_INVALID_WEEK_START, "début de semaine invalide"),
(
k::ERR_LAST_ADMIN,
"impossible de supprimer le dernier administrateur actif",
@@ -2311,6 +2353,13 @@ const FR: &[(&str, &str)] = &[
"Partager avec un autre compte de ce serveur.",
),
("pim_show", "Afficher"),
("pim_skip_component", "ce type n'est pas accepté ici"),
("pim_skip_invalid", "données invalides ou incomplètes"),
("pim_skip_size", "trop volumineux"),
(
"pim_skip_uid",
"le même UID existe déjà dans un autre calendrier",
),
("pim_subscribe", "S'abonner"),
("pim_tentative", "Peut-être"),
("pim_this_event", "Seulement cet événement"),
@@ -2563,6 +2612,14 @@ const FR: &[(&str, &str)] = &[
("user_updated", "Utilisateur mis à jour"),
("user_you", "(vous)"),
("users", "Utilisateurs"),
("week_monday", "Lundi"),
("week_saturday", "Samedi"),
("week_start", "La semaine commence le"),
(
"week_start_desc",
"Le premier jour de la semaine dans le calendrier.",
),
("week_sunday", "Dimanche"),
("your_shares", "Vos partages"),
];
▾Mweb/src/views/admin.rs
@@ -135,6 +135,7 @@ pub fn SettingsView(
let (thumbnails, set_thumbnails) = signal(Option::<bool>::None);
// Default root: Some(None) = the folder overview.
let (default_root, set_default_root) = signal(Option::<Option<i64>>::None);
let (week_start, set_week_start) = signal(Option::<u8>::None);
// Without `--cache` the server cannot make thumbnails, so hide the row
// rather than offer a setting that does nothing.
let thumbs_available = move || me.get().is_some_and(|m| m.thumbnails_available);
@@ -144,6 +145,7 @@ pub fn SettingsView(
set_thumbnails.set(Some(u.thumbnails));
set_language.set(Some(u.language.clone()));
set_default_root.set(Some(u.default_root_id));
set_week_start.set(Some(u.week_start));
}
});
// Saves both profile settings in one call.
@@ -157,9 +159,12 @@ pub fn SettingsView(
let Some(root) = default_root.get() else {
return;
};
let Some(ws) = week_start.get() else { return };
set_profile_busy.set(true);
spawn_local(async move {
match api::update_profile(Some(v), Some(th), Some(lang.clone()), Some(root)).await {
match api::update_profile(Some(v), Some(th), Some(lang.clone()), Some(root), Some(ws))
.await
{
Ok(m) => {
show(toast, i18n::t(i18n::k::PROFILE_SAVED).to_string());
// The response already carries the fresh /me (which also
@@ -169,6 +174,7 @@ pub fn SettingsView(
set_thumbnails.set(Some(th));
set_language.set(Some(lang));
set_default_root.set(Some(root));
set_week_start.set(Some(ws));
}
Err(e) => show(
toast,
@@ -274,8 +280,8 @@ pub fn SettingsView(
})}
</div>
<Show when=move || tab.get() == SettingsTab::Profile>
{move || match (language.get(), default_root.get()) {
(Some(lang), Some(root)) => {
{move || match (language.get(), default_root.get(), week_start.get()) {
(Some(lang), Some(root), Some(ws)) => {
let lang_val = lang.clone().unwrap_or_default();
let roots = me.get().map(|m| m.roots).unwrap_or_default();
view! {
@@ -310,6 +316,22 @@ pub fn SettingsView(
<option value="fr" selected=lang_val == "fr">{i18n::t(i18n::k::LANG_FR)}</option>
</select>
</div>
<div class="setting-row setting-row-select">
<span>
<span class="setting-label">{i18n::t(i18n::k::WEEK_START)}</span>
<span class="setting-desc">{i18n::t(i18n::k::WEEK_START_DESC)}</span>
</span>
<select
on:change=move |ev| {
set_week_start.set(event_target_value(&ev).parse().ok())
}
>
{[(1u8, i18n::k::WEEK_MONDAY), (0, i18n::k::WEEK_SUNDAY), (6, i18n::k::WEEK_SATURDAY)]
.map(|(d, label)| view! {
<option value=d.to_string() selected=ws == d>{i18n::t(label)}</option>
})}
</select>
</div>
<div class="setting-row setting-row-select">
<span>
<span class="setting-label">{i18n::t(i18n::k::DEFAULT_ROOT_LABEL)}</span>
▾Mweb/src/views/calendar.rs
@@ -16,7 +16,7 @@ use crate::api::{
use crate::components::icon::icon_svg;
use crate::components::modal::Modal;
use crate::components::toast::{ToastMsg, show, show_error};
use crate::i18n::{self, Locale, k};
use crate::i18n::{self, k};
use crate::icons::IconName;
use crate::router::{self, Location, Section, navigate};
use crate::util::{intl, owned_window_listener};
@@ -64,12 +64,9 @@ pub fn grid_start(y: i32, m: u32, week_start: u32) -> i64 {
/// The grid always shows six weeks, so its height never jumps.
pub const GRID_DAYS: i64 = 42;
/// Sunday for English, Monday for German and French.
fn week_start(locale: Locale) -> u32 {
match locale {
Locale::En => 0,
Locale::De | Locale::Fr => 1,
}
/// Cancelled, or declined by the calendar's owner: shown struck through.
fn struck(i: &api::PimInstance) -> bool {
i.status.as_deref() == Some("CANCELLED") || i.partstat.as_deref() == Some("DECLINED")
}
fn add_months(y: i32, m: u32, n: i32) -> (i32, u32) {
@@ -488,6 +485,8 @@ pub fn CalendarMain(
collections: ReadSignal<Option<Vec<PimCollectionInfo>>>,
hidden: RwSignal<HashSet<i64>>,
loc: ReadSignal<Location>,
/// 0 is Sunday. From the profile.
week_start: Signal<u32>,
) -> impl IntoView {
let view = Memo::new(move |_| loc.with(|l| CalView::from_path(&l.path)));
let opened = Memo::new(move |_| loc.with(|l| l.open.as_deref().and_then(Opened::parse)));
@@ -508,7 +507,7 @@ pub fn CalendarMain(
month_key.set_value(Some((y, m)));
month.set(None);
}
let start = grid_start(y, m, week_start(i18n::current_untracked()));
let start = grid_start(y, m, week_start.get());
let seq = month_seq.get_value() + 1;
month_seq.set_value(seq);
spawn_local(async move {
@@ -709,7 +708,7 @@ pub fn CalendarMain(
let body = move || match view.get() {
CalView::Month(y, m) => view! {
<MonthView y=y m=m data=month shown=shown/>
<MonthView y=y m=m week_start=week_start.get() data=month shown=shown/>
}
.into_any(),
CalView::Agenda => view! {
@@ -795,7 +794,7 @@ fn day_row(s: &Shown, day: i64) -> impl IntoView + use<> {
.summary
.clone()
.unwrap_or_else(|| i18n::t(k::PIM_NO_TITLE).to_string());
let cancelled = s.inst.status.as_deref() == Some("CANCELLED");
let cancelled = struck(&s.inst);
let location = s.inst.location.clone();
view! {
<button class="cal-row" class:cal-cancelled=cancelled on:click=move |_| o.open()>
@@ -813,10 +812,11 @@ fn day_row(s: &Shown, day: i64) -> impl IntoView + use<> {
fn MonthView(
y: i32,
m: u32,
week_start: u32,
data: RwSignal<Option<Result<api::PimInstances, String>>>,
shown: impl Fn(&api::PimInstances) -> Vec<Shown> + Copy + Send + Sync + 'static,
) -> impl IntoView {
let start = grid_start(y, m, week_start(i18n::current_untracked()));
let start = grid_start(y, m, week_start);
let now = today();
let in_month = move |d: i64| {
let (yy, mm, _) = civil_from_days(d);
@@ -900,7 +900,7 @@ fn MonthView(
.clone()
.unwrap_or_else(|| i18n::t(k::PIM_NO_TITLE).to_string());
let time = (!bar).then(|| fmt_min(s.span.first, s.span.start_min));
let cancelled = s.inst.status.as_deref() == Some("CANCELLED");
let cancelled = struck(&s.inst);
let tip = match &time {
Some(t) => format!("{t} {title}"),
None => title.clone(),
@@ -1460,11 +1460,12 @@ mod tests {
assert_eq!(civil_from_days(sunday), (2026, 9, 27));
assert_eq!(civil_from_days(monday), (2026, 9, 28));
assert_eq!(weekday(monday), 1);
assert_eq!(civil_from_days(grid_start(2026, 10, 6)), (2026, 9, 26));
// A month starting on the week start begins the grid itself, and
// six weeks always cover the whole month.
assert_eq!(grid_start(2026, 6, 1), day(2026, 6, 1));
for (y, m) in [(2026, 2), (2026, 8), (2027, 1)] {
for ws in [0, 1] {
for ws in [0, 1, 6] {
let s = grid_start(y, m, ws);
let (ny, nm) = add_months(y, m, 1);
assert!(s <= day(y, m, 1) && day(ny, nm, 1) <= s + GRID_DAYS);
▾Mweb/src/views/contacts.rs
@@ -23,8 +23,9 @@ fn parse_open(s: &str) -> Option<(i64, String)> {
/// Up to two initials for a contact without photo.
fn initials(name: &str) -> String {
// Words that start with a digit or bracket, like "(4th", give no letter.
name.split_whitespace()
.filter_map(|w| w.chars().next())
.filter_map(|w| w.chars().next().filter(|c| c.is_alphabetic()))
.take(2)
.flat_map(char::to_uppercase)
.collect()
@@ -71,6 +72,19 @@ fn fmt_vcard_date(s: &str) -> String {
/// Initials, covered by the photo when there is one. A photo that fails to
/// load hides itself and leaves the initials.
/// vCard TYPE values in the UI language. Unknown ones stay as written.
fn type_label(t: &str) -> String {
let key = match t.to_ascii_lowercase().as_str() {
"work" => k::CT_TYPE_WORK,
"home" => k::CT_TYPE_HOME,
"cell" | "mobile" => k::CT_TYPE_CELL,
"fax" => k::CT_TYPE_FAX,
"other" => k::CT_TYPE_OTHER,
_ => return t.to_string(),
};
i18n::t(key).to_string()
}
fn avatar(name: &str, photo: Option<String>, class: &'static str) -> impl IntoView + use<> {
view! {
<span class=format!("ct-avatar ct-initials {class}") aria-hidden="true">
@@ -240,7 +254,7 @@ pub fn ContactsMain(
{items
.into_iter()
.map(|l| {
let label = l.label.clone();
let label = l.label.as_deref().map(type_label);
let value = match href(&l.value) {
Some(h) => {
// mailto: and tel: open an app, not a tab.
@@ -351,6 +365,7 @@ mod tests {
assert_eq!(vcard_date("1980-13-01"), None);
assert_eq!(initials("anna maria berg"), "AM");
assert_eq!(initials("ümit"), "Ü");
assert_eq!(initials("Atrium (4th floor)"), "AF");
assert_eq!(parse_open("3/a/b.vcf"), Some((3, "a/b.vcf".to_string())));
}
}
▾Mweb/src/views/pim.rs
@@ -91,6 +91,20 @@ pub fn browser_tz() -> String {
/// The calendar or contacts section: the collection list, and the view of
/// what the visible collections hold.
/// A readable form of the precondition an import skip names.
fn skip_reason(reason: &str) -> String {
let key = match reason {
"valid-calendar-data" | "valid-address-data" | "valid-calendar-object-resource" => {
k::PIM_SKIP_INVALID
}
"supported-calendar-component" => k::PIM_SKIP_COMPONENT,
"unique-scheduling-object-resource" => k::PIM_SKIP_UID,
"max-resource-size" => k::PIM_SKIP_SIZE,
_ => return reason.to_string(),
};
i18n::t(key).to_string()
}
#[component]
pub fn PimView(
kind: PimCollectionKind,
@@ -112,7 +126,17 @@ pub fn PimView(
reload.run(());
let main = match kind {
PimCollectionKind::Calendar => {
view! { <CalendarMain collections=collections hidden=hidden loc=loc/> }.into_any()
let week_start = Signal::derive(move || {
me.with(|m| {
m.as_ref()
.and_then(|m| m.user.as_ref())
.map_or(1, |u| u32::from(u.week_start))
})
});
view! {
<CalendarMain collections=collections hidden=hidden loc=loc week_start=week_start/>
}
.into_any()
}
PimCollectionKind::Addressbook => {
view! { <ContactsMain collections=collections hidden=hidden loc=loc/> }.into_any()
@@ -174,6 +198,9 @@ fn CollectionList(
<button
class="pim-toggle"
title=move || if is_hidden() { i18n::t(k::PIM_SHOW) } else { i18n::t(k::PIM_HIDE) }
// Pressed means shown. A label that flips with the state
// would read "Hide, pressed" to a screen reader.
aria-label=name.clone()
aria-pressed=move || (!is_hidden()).to_string()
on:click=toggle
>
@@ -726,11 +753,14 @@ fn LinksSection(info: PimCollectionInfo) -> impl IntoView {
.strip_prefix("https://")
.or_else(|| full.strip_prefix("http://"))
.map(|rest| format!("webcal://{rest}"));
let mut meta = vec![if l.busy_only {
i18n::t(k::PIM_LINK_BUSY).to_string()
} else {
i18n::t(k::PIM_LINK_FULL).to_string()
}];
let mut meta = Vec::new();
if calendar {
meta.push(if l.busy_only {
i18n::t(k::PIM_LINK_BUSY).to_string()
} else {
i18n::t(k::PIM_LINK_FULL).to_string()
});
}
if let Some(e) = &l.expires_at {
meta.push(i18n::t_fmt(k::META_EXPIRES, &local_date_time(e)));
}
@@ -960,7 +990,7 @@ fn ImportSection(info: PimCollectionInfo, on_changed: Callback<()>) -> impl Into
{r.skipped
.into_iter()
.map(|s| view! {
<li>{format!("{} · {}", s.uid.unwrap_or_else(|| "—".to_string()), s.reason)}</li>
<li>{format!("{} · {}", s.uid.unwrap_or_else(|| "—".to_string()), skip_reason(&s.reason))}</li>
})
.collect::<Vec<_>>()}
{(more > 0).then(|| view! { <li class="muted">{i18n::t_fmt(k::AND_MORE, &more.to_string())}</li> })}
▾Mweb/src/views/share_page.rs
@@ -54,6 +54,7 @@ pub fn ShareView(token: String, loc: ReadSignal<Location>) -> impl IntoView {
single_click_open: false,
thumbnails: true,
language: None,
week_start: 1,
default_root_id: None,
auth_mode: Default::default(),
has_password: false,
▾Mweb/src/views/shares.rs
@@ -753,14 +753,15 @@ fn AdminFeeds() -> impl IntoView {
let url = crate::router::server_url(&f.link.path);
let copy = url.clone();
let id = f.link.id;
let mut meta = vec![
f.owner_name.clone(),
if f.link.busy_only {
let mut meta = vec![f.owner_name.clone()];
// Address book feeds have no detail level.
if f.kind == api_types::PimCollectionKind::Calendar {
meta.push(if f.link.busy_only {
i18n::t(i18n::k::PIM_LINK_BUSY).to_string()
} else {
i18n::t(i18n::k::PIM_LINK_FULL).to_string()
},
];
});
}
if let Some(e) = &f.link.expires_at {
meta.push(i18n::t_fmt(i18n::k::META_EXPIRES, &format_date(e)));
}
▾Mweb/src/views/shell.rs
@@ -397,6 +397,7 @@ mod tests {
single_click_open: false,
thumbnails: false,
language: None,
week_start: 1,
default_root_id: default,
auth_mode: Default::default(),
has_password: true,