feat(sync): nudge long-time users without sync to set it up (#8637)

* feat(sync): nudge long-time users without sync to set it up

Offline-first means a user who never configures sync has no backup at
all; clearing browser data or losing the device wipes everything. Once
the app has clearly been used for a while AND holds real data, show a
calm, low-priority banner once encouraging sync setup.

Trigger combines wall-clock age (>= 7 days since a lazily-seeded
FIRST_USE_TIMESTAMP) with a task-count gate (>= 20 tasks), so it is
robust both to users who restart many times a day and to those who
leave the app running for weeks (where app-start count fails), and
never nags an empty/dormant install. Shown at most once: both "Set up
sync" and "Not now" persist a dismissed flag; a configured provider
suppresses it entirely.

Reuses the existing banner system and mirrors NoteStartupBannerService.

* refactor(sync): apply review feedback to data-safety nudge

- Rename LS.FIRST_USE_TIMESTAMP -> SYNC_SAFETY_FIRST_SEEN and document that
  it is not a true install date (seeded at upgrade time for existing
  installs), so no other feature reuses it as one.
- Tests: add an unhydrated-config (undefined) skip case and assert the
  task-count selector, guarding the earlier race fix and a selector swap.
- i18n: match each locale's existing register for the nudge message
  (pt/cs/sk/tr -> formal, id -> informal) per translation review.

* test(planner): stabilize add-subtask-from-detail focus race

Wait for the detail panel's deferred open-time auto-focus to settle before
opening the inline subtask draft. The auto-focus lands ~200ms after the panel
opens and, if it fires after the draft input is focused, steals focus and
blur-closes the input — making toBeFocused() flake with 'element(s) not found'.
Mirrors the guard already used in add-subtask-with-detail-panel-open.spec.ts.
This commit is contained in:
Johannes Millan 2026-06-29 19:45:42 +02:00 committed by GitHub
parent 06a7425698
commit a1c059855e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 447 additions and 0 deletions

View file

@ -34,6 +34,17 @@ test.describe('Planner: add subtask from detail panel', () => {
const panel = page.locator(DETAIL_PANEL);
await expect(panel).toBeVisible({ timeout: 5000 });
// Let the panel's deferred open-time auto-focus land first. It fires ~200ms
// after open (delay(50) + a 150ms guarded focus) and moves focus to the
// first detail item; if it lands *after* we open the inline draft below it
// steals focus from the input, blur-closing it — the input then vanishes
// mid-test and `toBeFocused()` fails with "element(s) not found".
await expect
.poll(async () =>
page.evaluate(() => !!document.activeElement?.closest('task-detail-panel')),
)
.toBe(true);
// The sub-task section starts collapsed; expand it to reveal the button.
await panel
.locator('mat-expansion-panel-header')

View file

@ -59,6 +59,7 @@ import { TaskService } from './features/tasks/task.service';
import { MatMenuItem } from '@angular/material/menu';
import { MatIcon } from '@angular/material/icon';
import { NoteStartupBannerService } from './features/note/note-startup-banner.service';
import { SyncSafetyBannerService } from './imex/sync/sync-safety-banner.service';
import { ProjectService } from './features/project/project.service';
import { TagService } from './features/tag/tag.service';
import { ContextMenuComponent } from './ui/context-menu/context-menu.component';
@ -154,6 +155,7 @@ export class AppComponent implements OnDestroy, AfterViewInit {
private _tagService = inject(TagService);
private _destroyRef = inject(DestroyRef);
private _noteStartupBannerService = inject(NoteStartupBannerService);
private _syncSafetyBannerService = inject(SyncSafetyBannerService);
private _ngZone = inject(NgZone);
private _document = inject(DOCUMENT, { optional: true });
private _startupService = inject(StartupService);
@ -309,6 +311,7 @@ export class AppComponent implements OnDestroy, AfterViewInit {
.pipe(take(1))
.subscribe(() => {
void this._noteStartupBannerService.showLastNoteIfNeeded();
this._syncSafetyBannerService.showReminderIfNeeded();
});
// ! For keyboard shortcuts to work correctly with any layouts (QWERTZ/AZERTY/etc) - user's keyboard layout must be presaved

View file

@ -13,6 +13,7 @@ export enum BannerId {
FocusModeSessionDone = 'FocusModeSessionDone',
StartupNote = 'StartupNote',
DeadlinesToday = 'DeadlinesToday',
SyncSafetyReminder = 'SyncSafetyReminder',
}
export const BANNER_SORT_PRIO_MAP = {
@ -28,6 +29,7 @@ export const BANNER_SORT_PRIO_MAP = {
[BannerId.StartupNote]: 2,
[BannerId.Offline]: 0,
[BannerId.InstallWebApp]: 0,
[BannerId.SyncSafetyReminder]: 0,
} as const;
export interface BannerAction {

View file

@ -12,6 +12,11 @@ export enum DB {
export enum LS {
APP_START_COUNT = 'APP_START_COUNT',
APP_START_COUNT_LAST_START_DAY = 'APP_START_COUNT_LAST_START_DAY',
// Epoch ms first observed by SyncSafetyBannerService (seeded once, lazily, on
// its first run). Used only to tell "used for a while" by wall-clock time for
// the sync-setup nudge. NOT a true install date: for installs that predate
// this feature it is seeded at upgrade time, so don't reuse it as one.
SYNC_SAFETY_FIRST_SEEN = 'SUP_SYNC_SAFETY_FIRST_SEEN',
RATE_DIALOG_STATE = 'SUP_RATE_DIALOG_STATE',
// Set on an unhandled error or any detected data damage; read by the rating
// prompt to hold off for a cooldown after a bad experience. Time only.
@ -33,6 +38,10 @@ export enum LS {
LAST_NOTE_BANNER_DAY = 'SUP_LAST_NOTE_BANNER_DAY',
// Set once the user acts on or dismisses the "set up sync to keep your data
// safe" startup banner, so the nudge is shown at most once ever.
SYNC_SAFETY_NUDGE_DISMISSED = 'SUP_SYNC_SAFETY_NUDGE_DISMISSED',
SELECTED_TIME_VIEW = 'SELECTED_TIME_VIEW',
SCHEDULE_WEEK_ROW_HEIGHT = 'SUP_SCHEDULE_WEEK_ROW_HEIGHT',

View file

@ -0,0 +1,169 @@
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { SyncSafetyBannerService } from './sync-safety-banner.service';
import { BannerService } from '../../core/banner/banner.service';
import { BannerId, Banner } from '../../core/banner/banner.model';
import { LS } from '../../core/persistence/storage-keys.const';
import { GlobalConfigService } from '../../features/config/global-config.service';
import { OnboardingHintService } from '../../features/onboarding/onboarding-hint.service';
import { SyncConfig } from '../../features/config/global-config.model';
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
import { selectTaskFeatureState } from '../../features/tasks/store/task.selectors';
const DAY_MS = 24 * 60 * 60 * 1000;
const NOW = 1_700_000_000_000;
const UNCONFIGURED: Partial<SyncConfig> = { isEnabled: false, syncProvider: null };
const CONFIGURED: Partial<SyncConfig> = {
isEnabled: true,
syncProvider: SyncProviderId.Dropbox,
};
describe('SyncSafetyBannerService', () => {
let service: SyncSafetyBannerService;
let bannerService: jasmine.SpyObj<BannerService>;
let matDialog: jasmine.SpyObj<MatDialog>;
let syncSpy: jasmine.Spy;
let selectSignalSpy: jasmine.Spy;
let lsStore: Record<string, string>;
let taskIds: string[];
const lastBanner = (): Banner =>
bannerService.open.calls.mostRecent().args[0] as Banner;
const daysAgoTs = (n: number): string => {
const offset = n * DAY_MS;
return (NOW - offset).toString();
};
const manyTasks = (): string[] => Array.from({ length: 30 }, (_, i) => `t${i}`);
// Defaults to a clearly-eligible user: first used 8 days ago, 30 tasks.
const setEligible = (): void => {
lsStore[LS.SYNC_SAFETY_FIRST_SEEN] = daysAgoTs(8);
taskIds = manyTasks();
};
beforeEach(() => {
lsStore = {};
taskIds = [];
spyOn(Date, 'now').and.returnValue(NOW);
spyOn(localStorage, 'getItem').and.callFake((k) => lsStore[k] ?? null);
spyOn(localStorage, 'setItem').and.callFake((k, v) => {
lsStore[k] = v;
});
spyOn(OnboardingHintService, 'isOnboardingInProgress').and.returnValue(false);
bannerService = jasmine.createSpyObj<BannerService>('BannerService', ['open']);
matDialog = jasmine.createSpyObj<MatDialog>('MatDialog', ['open']);
syncSpy = jasmine.createSpy('sync').and.returnValue(UNCONFIGURED);
// Reads live `taskIds` so each test can vary the count before invoking.
selectSignalSpy = jasmine
.createSpy('selectSignal')
.and.callFake(() => () => ({ ids: taskIds }));
TestBed.configureTestingModule({
providers: [
SyncSafetyBannerService,
{ provide: BannerService, useValue: bannerService },
{ provide: MatDialog, useValue: matDialog },
{ provide: GlobalConfigService, useValue: { sync: syncSpy } },
{ provide: Store, useValue: { selectSignal: selectSignalSpy } },
],
});
service = TestBed.inject(SyncSafetyBannerService);
});
it('shows the banner once used for a while with real data and no sync', () => {
setEligible();
service.showReminderIfNeeded();
expect(bannerService.open).toHaveBeenCalledTimes(1);
expect(lastBanner().id).toBe(BannerId.SyncSafetyReminder);
// Guards against accidentally swapping in a different task selector.
expect(selectSignalSpy).toHaveBeenCalledWith(selectTaskFeatureState);
});
it('does not show while sync config is still unhydrated (undefined)', () => {
setEligible();
syncSpy.and.returnValue(undefined);
service.showReminderIfNeeded();
expect(bannerService.open).not.toHaveBeenCalled();
// ...but the first-use clock is still seeded for next time.
expect(lsStore[LS.SYNC_SAFETY_FIRST_SEEN]).toBe(daysAgoTs(8));
});
it('shows exactly at the thresholds (7 days, 20 tasks)', () => {
lsStore[LS.SYNC_SAFETY_FIRST_SEEN] = daysAgoTs(7);
taskIds = Array.from({ length: 20 }, (_, i) => `t${i}`);
service.showReminderIfNeeded();
expect(bannerService.open).toHaveBeenCalledTimes(1);
});
it('seeds the first-use timestamp on first run and does not show yet', () => {
taskIds = manyTasks();
service.showReminderIfNeeded();
expect(lsStore[LS.SYNC_SAFETY_FIRST_SEEN]).toBe(NOW.toString());
expect(bannerService.open).not.toHaveBeenCalled();
});
it('still seeds the first-use clock during onboarding (without showing)', () => {
(OnboardingHintService.isOnboardingInProgress as jasmine.Spy).and.returnValue(true);
taskIds = manyTasks();
service.showReminderIfNeeded();
expect(lsStore[LS.SYNC_SAFETY_FIRST_SEEN]).toBe(NOW.toString());
expect(bannerService.open).not.toHaveBeenCalled();
});
it('does not show before the time threshold even with lots of data', () => {
lsStore[LS.SYNC_SAFETY_FIRST_SEEN] = daysAgoTs(3);
taskIds = manyTasks();
service.showReminderIfNeeded();
expect(bannerService.open).not.toHaveBeenCalled();
});
it('does not show with too little data even after the time threshold', () => {
lsStore[LS.SYNC_SAFETY_FIRST_SEEN] = daysAgoTs(8);
taskIds = ['a', 'b', 'c', 'd']; // example-task-sized
service.showReminderIfNeeded();
expect(bannerService.open).not.toHaveBeenCalled();
});
it('does not show again once dismissed', () => {
setEligible();
lsStore[LS.SYNC_SAFETY_NUDGE_DISMISSED] = 'true';
service.showReminderIfNeeded();
expect(bannerService.open).not.toHaveBeenCalled();
});
it('does not interrupt onboarding', () => {
setEligible();
(OnboardingHintService.isOnboardingInProgress as jasmine.Spy).and.returnValue(true);
service.showReminderIfNeeded();
expect(bannerService.open).not.toHaveBeenCalled();
});
it('does not show when sync is already configured', () => {
setEligible();
syncSpy.and.returnValue(CONFIGURED);
service.showReminderIfNeeded();
expect(bannerService.open).not.toHaveBeenCalled();
});
it('persists dismissal when the user clicks "Not now"', () => {
setEligible();
service.showReminderIfNeeded();
lastBanner().action2!.fn();
expect(lsStore[LS.SYNC_SAFETY_NUDGE_DISMISSED]).toBe('true');
});
it('persists dismissal and opens the sync dialog on "Set up sync"', () => {
const openDialogSpy = spyOn(
service as unknown as { _openSyncCfgDialog: () => Promise<void> },
'_openSyncCfgDialog',
).and.resolveTo();
setEligible();
service.showReminderIfNeeded();
lastBanner().action!.fn();
expect(lsStore[LS.SYNC_SAFETY_NUDGE_DISMISSED]).toBe('true');
expect(openDialogSpy).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,108 @@
import { inject, Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { BannerService } from '../../core/banner/banner.service';
import { BannerId } from '../../core/banner/banner.model';
import { LS } from '../../core/persistence/storage-keys.const';
import { GlobalConfigService } from '../../features/config/global-config.service';
import { OnboardingHintService } from '../../features/onboarding/onboarding-hint.service';
import { selectTaskFeatureState } from '../../features/tasks/store/task.selectors';
import { devError } from '../../util/dev-error';
import { T } from '../../t.const';
const DAY_MS = 24 * 60 * 60 * 1000;
// "Used for a while": wall-clock days since the app was first opened on this
// device. Wall-clock (not app-start count) so it is robust both to users who
// restart many times a day and to those who leave the app running for weeks.
const MIN_DAYS_SINCE_FIRST_USE = 7;
// ...and there must be a non-trivial amount of data actually worth protecting:
// a fresh install seeds only 4 example tasks, so this keeps us from nudging an
// empty/dormant app even after the time threshold passes.
const MIN_TASKS = 20;
/**
* Offline-first means a user who never configures sync has no backup at all.
* Once they have clearly used the app for a while AND accumulated real data,
* gently (and exactly once) encourage setting up sync so their data stays safe.
* Mirrors the calm, gated-once pattern of NoteStartupBannerService.
*/
@Injectable({ providedIn: 'root' })
export class SyncSafetyBannerService {
private readonly _bannerService = inject(BannerService);
private readonly _globalConfigService = inject(GlobalConfigService);
private readonly _matDialog = inject(MatDialog);
private readonly _store = inject(Store);
private readonly _taskState = this._store.selectSignal(selectTaskFeatureState);
showReminderIfNeeded(): void {
// Once-ever guard: already acted on / dismissed.
if (localStorage.getItem(LS.SYNC_SAFETY_NUDGE_DISMISSED)) {
return;
}
// Start (or read) the first-use clock. Seeding here means it begins ticking
// the first time the app boots on this device; an install that already
// exists when this feature ships starts its clock now (a deliberate grace
// period rather than nudging every existing user at once).
const firstUseMs = this._getOrSeedFirstUse();
// Don't interrupt the first-run onboarding flow.
if (OnboardingHintService.isOnboardingInProgress()) {
return;
}
// Already using sync → nothing to nudge. Treat "config not loaded yet"
// (undefined) as a skip too, so a synced user is never nudged in a race.
const sync = this._globalConfigService.sync();
if (!sync || (sync.isEnabled && sync.syncProvider)) {
return;
}
// Used for a while?
if (Date.now() - firstUseMs < MIN_DAYS_SINCE_FIRST_USE * DAY_MS) {
return;
}
// Enough data to be worth protecting?
if ((this._taskState().ids?.length ?? 0) < MIN_TASKS) {
return;
}
this._bannerService.open({
id: BannerId.SyncSafetyReminder,
msg: T.APP.B_SYNC_SAFETY.MSG,
ico: 'cloud_off',
action: {
label: T.APP.B_SYNC_SAFETY.SETUP,
fn: () => {
this._dismissForever();
void this._openSyncCfgDialog().catch(devError);
},
},
action2: {
label: T.APP.B_SYNC_SAFETY.DISMISS,
fn: () => this._dismissForever(),
},
isHideDismissBtn: true,
});
}
private _getOrSeedFirstUse(): number {
const stored = +(localStorage.getItem(LS.SYNC_SAFETY_FIRST_SEEN) || 0);
if (stored) {
return stored;
}
const now = Date.now();
localStorage.setItem(LS.SYNC_SAFETY_FIRST_SEEN, now.toString());
return now;
}
private _dismissForever(): void {
localStorage.setItem(LS.SYNC_SAFETY_NUDGE_DISMISSED, 'true');
}
private async _openSyncCfgDialog(): Promise<void> {
const { DialogSyncCfgComponent } =
await import('./dialog-sync-cfg/dialog-sync-cfg.component');
this._matDialog.open(DialogSyncCfgComponent);
}
}

View file

@ -6,6 +6,11 @@ const T = {
MSG: 'APP.B_INSTALL.MSG',
},
B_OFFLINE: 'APP.B_OFFLINE',
B_SYNC_SAFETY: {
DISMISS: 'APP.B_SYNC_SAFETY.DISMISS',
MSG: 'APP.B_SYNC_SAFETY.MSG',
SETUP: 'APP.B_SYNC_SAFETY.SETUP',
},
CONTEXT_MENU: {
CHANGE_PROJECT_SETTINGS: 'APP.CONTEXT_MENU.CHANGE_PROJECT_SETTINGS',
CHANGE_TAG_SETTINGS: 'APP.CONTEXT_MENU.CHANGE_TAG_SETTINGS',

View file

@ -6,6 +6,11 @@
"MSG": "هل تريد تثبيت الإنتاجية الفائقة كتطبيق ويب؟"
},
"B_OFFLINE": "أنت غير متصل بالإنترنت. لن تعمل مزامنة بيانات موفر المشكلة وطلبها.",
"B_SYNC_SAFETY": {
"DISMISS": "ليس الآن",
"MSG": "أنت تستخدم Super Productivity منذ فترة. قم بإعداد المزامنة لعمل نسخة احتياطية من بياناتك والحفاظ عليها آمنة عبر جميع أجهزتك.",
"SETUP": "إعداد المزامنة"
},
"CONTEXT_MENU": {
"CHANGE_BACKGROUND": "تغيير الخلفية",
"CHANGE_PROJECT_SETTINGS": "تغيير إعدادات المشروع",

View file

@ -6,6 +6,11 @@
"MSG": "Chcete nainstalovat Super Produktivitu jako PWA?"
},
"B_OFFLINE": "Jste odpojeni od internetu. Synchronizace a požadavky na data poskytovatele problémů nebudou fungovat.",
"B_SYNC_SAFETY": {
"DISMISS": "Teď ne",
"MSG": "Super Productivity používáte už nějakou dobu. Nastavte synchronizaci, abyste zálohovali svá data a udrželi je v bezpečí na všech zařízeních.",
"SETUP": "Nastavit synchronizaci"
},
"CONTEXT_MENU": {
"CHANGE_BACKGROUND": "Změnit pozadí"
},

View file

@ -6,6 +6,11 @@
"MSG": "Möchtest du Super Productivity als PWA installieren?"
},
"B_OFFLINE": "Du bist vom Internet getrennt. Das Synchronisieren und Anfordern von Daten des Issue-Providers funktioniert nicht.",
"B_SYNC_SAFETY": {
"DISMISS": "Jetzt nicht",
"MSG": "Du nutzt Super Productivity schon eine Weile. Richte die Synchronisierung ein, um deine Daten zu sichern und geräteübergreifend zu schützen.",
"SETUP": "Synchronisierung einrichten"
},
"CONTEXT_MENU": {
"CHANGE_PROJECT_SETTINGS": "Projekteinstellungen ändern",
"CHANGE_TAG_SETTINGS": "Tag-Einstellungen ändern"

View file

@ -6,6 +6,11 @@
"MSG": "Do you want to install Super Productivity as a PWA (Progressive Web App)?"
},
"B_OFFLINE": "You are disconnected from the internet. Syncing and requesting issue provider data will not work.",
"B_SYNC_SAFETY": {
"DISMISS": "Not now",
"MSG": "You've been using Super Productivity for a while. Set up sync to back up your data and keep it safe across devices.",
"SETUP": "Set up sync"
},
"CONTEXT_MENU": {
"CHANGE_PROJECT_SETTINGS": "Change Project Settings",
"CHANGE_TAG_SETTINGS": "Change Tag Settings"

View file

@ -6,6 +6,11 @@
"MSG": "¿Quieres instalar Super Productivity como una PWA?"
},
"B_OFFLINE": "Estás desconectado de internet. La sincronización y la solicitud de datos al proveedor de incidencias no funcionarán.",
"B_SYNC_SAFETY": {
"DISMISS": "Ahora no",
"MSG": "Llevas un tiempo usando Super Productivity. Configura la sincronización para hacer una copia de seguridad de tus datos y mantenerlos seguros en todos tus dispositivos.",
"SETUP": "Configurar sincronización"
},
"CONTEXT_MENU": {
"CHANGE_BACKGROUND": "Cambiar fondo",
"CHANGE_PROJECT_SETTINGS": "Cambiar configuración del proyecto",

View file

@ -6,6 +6,11 @@
"MSG": "آیا میخواهید سوپر پروداکتیویتی به عنوان PWA نصب بشود؟"
},
"B_OFFLINE": "شما از اینترنت جدا هستید. همگام سازی و درخواست داده های ارائه دهنده مشکل کار نمی کند.",
"B_SYNC_SAFETY": {
"DISMISS": "اکنون نه",
"MSG": "مدتی است که از Super Productivity استفاده می‌کنید. همگام‌سازی را تنظیم کنید تا از داده‌های خود نسخه پشتیبان تهیه کرده و آن‌ها را در همه دستگاه‌ها ایمن نگه دارید.",
"SETUP": "تنظیم همگام‌سازی"
},
"CONTEXT_MENU": {
"CHANGE_BACKGROUND": "تغییر پیش زمینه"
},

View file

@ -6,6 +6,11 @@
"MSG": "Haluatko asentaa Super Productivityn PWA-sovellukseksi?"
},
"B_OFFLINE": "Olet irti internetistä. Synkronointi ja tiedustelut ongelmanantajien tiedoista eivät toimi.",
"B_SYNC_SAFETY": {
"DISMISS": "Ei nyt",
"MSG": "Olet käyttänyt Super Productivitya jo jonkin aikaa. Määritä synkronointi varmuuskopioidaksesi tietosi ja pitääksesi ne turvassa kaikilla laitteilla.",
"SETUP": "Määritä synkronointi"
},
"CONTEXT_MENU": {
"CHANGE_BACKGROUND": "Vaihda taustakuva"
},

View file

@ -6,6 +6,11 @@
"MSG": "Voulez-vous installer Super Productivity en tant que PWA ?"
},
"B_OFFLINE": "Vous êtes déconnecté d'Internet. La synchronisation et la demande des données du fournisseur de problèmes ne fonctionneront pas.",
"B_SYNC_SAFETY": {
"DISMISS": "Pas maintenant",
"MSG": "Vous utilisez Super Productivity depuis un certain temps. Configurez la synchronisation pour sauvegarder vos données et les garder en sécurité sur tous vos appareils.",
"SETUP": "Configurer la synchronisation"
},
"CONTEXT_MENU": {
"CHANGE_BACKGROUND": "Changer le fond",
"CHANGE_PROJECT_SETTINGS": "Modifier les paramètres du projet",

View file

@ -6,6 +6,11 @@
"MSG": "Želiš instalirati Super Productivity kao PWA (Progressive Web App)?"
},
"B_OFFLINE": "Nemaš vezu s internetom. Sinkronizacija i dohvaćanje podataka od usluge za praćenje problema neće raditi.",
"B_SYNC_SAFETY": {
"DISMISS": "Ne sada",
"MSG": "Već neko vrijeme koristiš Super Productivity. Postavi sinkronizaciju kako bi sigurnosno kopirao svoje podatke i čuvao ih sigurnima na svim uređajima.",
"SETUP": "Postavi sinkronizaciju"
},
"CONTEXT_MENU": {
"CHANGE_PROJECT_SETTINGS": "Promijeni postavke projekta",
"CHANGE_TAG_SETTINGS": "Promijeni postavke oznaka"

View file

@ -6,6 +6,11 @@
"MSG": "Apa kamu ingin memasang Super Productivity sebagai sebuah PWA?"
},
"B_OFFLINE": "Jaringanmu terputus dari internet. Sinkronasi dan meminta masalah penyedia data mungkin tidak akan bekerja.",
"B_SYNC_SAFETY": {
"DISMISS": "Nanti saja",
"MSG": "Kamu sudah menggunakan Super Productivity beberapa waktu. Siapkan sinkronisasi untuk mencadangkan datamu dan menjaganya tetap aman di semua perangkat.",
"SETUP": "Siapkan sinkronisasi"
},
"CONTEXT_MENU": {
"CHANGE_BACKGROUND": "Ubah Latar Belakang",
"CHANGE_PROJECT_SETTINGS": "Ubah pengaturan proyek",

View file

@ -6,6 +6,11 @@
"MSG": "Vuoi installare Super Productivity come PWA?"
},
"B_OFFLINE": "Sei disconnesso da Internet. La sincronizzazione e la richiesta dei dati del provider di problemi non funzioneranno.",
"B_SYNC_SAFETY": {
"DISMISS": "Non ora",
"MSG": "Usi Super Productivity da un po'. Configura la sincronizzazione per eseguire il backup dei tuoi dati e tenerli al sicuro su tutti i dispositivi.",
"SETUP": "Configura sincronizzazione"
},
"CONTEXT_MENU": {
"CHANGE_BACKGROUND": "Cambia sfondo",
"CHANGE_PROJECT_SETTINGS": "Modifica impostazioni progetto",

View file

@ -6,6 +6,11 @@
"MSG": "Super ProductivityをPWAとしてインストールしますか"
},
"B_OFFLINE": "インターネットに接続していません。課題プロバイダーデータの同期と要求は機能しません。",
"B_SYNC_SAFETY": {
"DISMISS": "後で",
"MSG": "Super Productivity をしばらくご利用いただいています。同期を設定して、データをバックアップし、すべてのデバイスで安全に保ちましょう。",
"SETUP": "同期を設定"
},
"CONTEXT_MENU": {
"CHANGE_BACKGROUND": "変更の背景",
"CHANGE_PROJECT_SETTINGS": "プロジェクト設定を変更",

View file

@ -6,6 +6,11 @@
"MSG": "PWA로 Super Productivity를 설치하시겠습니까?"
},
"B_OFFLINE": "인터넷 연결이 끊어졌습니다. 문제 제공자 데이터 동기화 및 요청이 작동하지 않습니다.",
"B_SYNC_SAFETY": {
"DISMISS": "나중에",
"MSG": "Super Productivity를 사용하신 지 꽤 되셨네요. 동기화를 설정하여 데이터를 백업하고 모든 기기에서 안전하게 보관하세요.",
"SETUP": "동기화 설정"
},
"UPDATE_WEB_APP": "새 버전을 사용할 수 있습니다. 새 버전을 로드하시겠습니까?",
"CONTEXT_MENU": {
"CHANGE_BACKGROUND": "배경 변경",

View file

@ -6,6 +6,11 @@
"MSG": "Vil du installere Super Productivity som en PWA?"
},
"B_OFFLINE": "Du er koblet fra internett. Synkronisering og å be om data fra leverandører fungerer ikke.",
"B_SYNC_SAFETY": {
"DISMISS": "Ikke nå",
"MSG": "Du har brukt Super Productivity en stund. Sett opp synkronisering for å sikkerhetskopiere dataene dine og holde dem trygge på tvers av enheter.",
"SETUP": "Sett opp synkronisering"
},
"CONTEXT_MENU": {
"CHANGE_BACKGROUND": "Endre bakgrunn"
},

View file

@ -6,6 +6,11 @@
"MSG": "Wilt u Super Productivity als PWA installeren?"
},
"B_OFFLINE": "Je hebt geen verbinding met internet. Het synchroniseren en opvragen van probleemprovidergegevens werkt niet.",
"B_SYNC_SAFETY": {
"DISMISS": "Niet nu",
"MSG": "Je gebruikt Super Productivity al een tijdje. Stel synchronisatie in om een back-up van je gegevens te maken en ze veilig te houden op al je apparaten.",
"SETUP": "Synchronisatie instellen"
},
"CONTEXT_MENU": {
"CHANGE_BACKGROUND": "Achtergrond wijzigen",
"CHANGE_PROJECT_SETTINGS": "Projectinstellingen wijzigen",

View file

@ -6,6 +6,11 @@
"MSG": "Czy chcesz zinstalować Super Productivity jako aplikację PWA?"
},
"B_OFFLINE": "Zostałeś odłączony od internetu. Synchronizacja nie będzie działać.",
"B_SYNC_SAFETY": {
"DISMISS": "Nie teraz",
"MSG": "Używasz Super Productivity już od jakiegoś czasu. Skonfiguruj synchronizację, aby utworzyć kopię zapasową danych i zapewnić im bezpieczeństwo na wszystkich urządzeniach.",
"SETUP": "Skonfiguruj synchronizację"
},
"CONTEXT_MENU": {
"CHANGE_PROJECT_SETTINGS": "Zmień ustawienia Projektu",
"CHANGE_TAG_SETTINGS": "Zmień ustawienia Tagów"

View file

@ -6,6 +6,11 @@
"MSG": "Deseja instalar o Super Productivity como um PWA (Progressive Web App)?"
},
"B_OFFLINE": "Você está desconectado da internet. A sincronização e a solicitação de dados de provedores de issues não funcionarão.",
"B_SYNC_SAFETY": {
"DISMISS": "Agora não",
"MSG": "Você usa o Super Productivity há algum tempo. Configure a sincronização para fazer backup dos seus dados e mantê-los seguros em todos os dispositivos.",
"SETUP": "Configurar sincronização"
},
"CONTEXT_MENU": {
"CHANGE_PROJECT_SETTINGS": "Alterar Configurações do Projeto",
"CHANGE_TAG_SETTINGS": "Alterar Configurações da Etiqueta"

View file

@ -6,6 +6,11 @@
"MSG": "Deseja instalar Super Productivity como PWA?"
},
"B_OFFLINE": "Você está desconectado da internet. A sincronização e solicitação de dados do provedor de problemas não funcionará.",
"B_SYNC_SAFETY": {
"DISMISS": "Agora não",
"MSG": "Já usa o Super Productivity há algum tempo. Configure a sincronização para fazer uma cópia de segurança dos seus dados e mantê-los seguros em todos os dispositivos.",
"SETUP": "Configurar sincronização"
},
"CONTEXT_MENU": {
"CHANGE_BACKGROUND": "Mudar Fundo",
"CHANGE_PROJECT_SETTINGS": "Alterar definições do projeto",

View file

@ -6,6 +6,11 @@
"MSG": "Vrei să instalezi Super Productivity ca PWA (Progressive Web App)?"
},
"B_OFFLINE": "Ești deconectat de la internet. Sincronizarea și solicitarea datelor furnizorului de probleme nu vor funcționa.",
"B_SYNC_SAFETY": {
"DISMISS": "Nu acum",
"MSG": "Folosești Super Productivity de ceva timp. Configurează sincronizarea pentru a face o copie de rezervă a datelor tale și a le păstra în siguranță pe toate dispozitivele.",
"SETUP": "Configurează sincronizarea"
},
"CONTEXT_MENU": {
"CHANGE_PROJECT_SETTINGS": "Schimbă setările proiectului",
"CHANGE_TAG_SETTINGS": "Schimbă setările etichetelor"

View file

@ -6,6 +6,11 @@
"MSG": "Vrei să instalezi Super Productivity ca PWA (Progressive Web App)?"
},
"B_OFFLINE": "Ești deconectat de la internet. Sincronizarea și solicitarea datelor furnizorului de probleme nu vor funcționa.",
"B_SYNC_SAFETY": {
"DISMISS": "Nu acum",
"MSG": "Folosești Super Productivity de ceva timp. Configurează sincronizarea pentru a face o copie de rezervă a datelor tale și a le păstra în siguranță pe toate dispozitivele.",
"SETUP": "Configurează sincronizarea"
},
"CONTEXT_MENU": {
"CHANGE_PROJECT_SETTINGS": "Schimbă setările proiectului",
"CHANGE_TAG_SETTINGS": "Schimbă setările etichetelor"

View file

@ -6,6 +6,11 @@
"MSG": "Хотите установить Super Productivity в качестве PWA?"
},
"B_OFFLINE": "Вы отключены от интернета. Синхронизация и запрос данных поставщика проблемы не будут работать.",
"B_SYNC_SAFETY": {
"DISMISS": "Не сейчас",
"MSG": "Вы пользуетесь Super Productivity уже некоторое время. Настройте синхронизацию, чтобы создать резервную копию данных и сохранить их в безопасности на всех устройствах.",
"SETUP": "Настроить синхронизацию"
},
"CONTEXT_MENU": {
"CHANGE_BACKGROUND": "Изменить фон",
"CHANGE_PROJECT_SETTINGS": "Изменить настройки проекта",

View file

@ -6,6 +6,11 @@
"MSG": "Chcete nainštalovať aplikáciu Super Productivity ako PWA?"
},
"B_OFFLINE": "Boli ste odpojení od internetu. Synchronizácia a vyžiadanie údajov nebudú fungovať.",
"B_SYNC_SAFETY": {
"DISMISS": "Teraz nie",
"MSG": "Super Productivity používate už nejaký čas. Nastavte synchronizáciu, aby ste zálohovali svoje údaje a udržali ich v bezpečí na všetkých zariadeniach.",
"SETUP": "Nastaviť synchronizáciu"
},
"CONTEXT_MENU": {
"CHANGE_BACKGROUND": "Zmeniť pozadie"
},

View file

@ -6,6 +6,11 @@
"MSG": "Vill du installera Super Productivity som en PWA?"
},
"B_OFFLINE": "Du är bortkopplad från internet. Synkronisering och begäran om data från ärendeleverantören fungerar inte.",
"B_SYNC_SAFETY": {
"DISMISS": "Inte nu",
"MSG": "Du har använt Super Productivity ett tag. Konfigurera synkronisering för att säkerhetskopiera dina data och hålla dem säkra på alla enheter.",
"SETUP": "Konfigurera synkronisering"
},
"CONTEXT_MENU": {
"CHANGE_BACKGROUND": "Ändra bakgrund"
},

View file

@ -6,6 +6,11 @@
"MSG": "Süper Verimliliği PWA olarak kurmak ister misiniz?"
},
"B_OFFLINE": "İnternet bağlantınız kesildi. Verileri senkronize etmek ve sağlayıcıdan sorunları istemek işe yaramaz.",
"B_SYNC_SAFETY": {
"DISMISS": "Şimdi değil",
"MSG": "Super Productivity'yi bir süredir kullanıyorsunuz. Verilerinizi yedeklemek ve tüm cihazlarınızda güvende tutmak için senkronizasyonu ayarlayın.",
"SETUP": "Senkronizasyonu ayarla"
},
"CONTEXT_MENU": {
"CHANGE_PROJECT_SETTINGS": "Proje Ayarlarını Değiştir",
"CHANGE_TAG_SETTINGS": "Etiket Ayarlarını Değiştir"

View file

@ -6,6 +6,11 @@
"MSG": "Бажаєте встановити Super Productivity як PWA?"
},
"B_OFFLINE": "Ви не підключені до інтернету. Синхронізація та запит даних з провайдера завдань не працюватимуть.",
"B_SYNC_SAFETY": {
"DISMISS": "Не зараз",
"MSG": "Ви користуєтеся Super Productivity вже певний час. Налаштуйте синхронізацію, щоб створити резервну копію даних і зберегти їх у безпеці на всіх пристроях.",
"SETUP": "Налаштувати синхронізацію"
},
"CONTEXT_MENU": {
"CHANGE_PROJECT_SETTINGS": "Змінити налаштування проекту",
"CHANGE_TAG_SETTINGS": "Змінити налаштування тегів"

View file

@ -6,6 +6,11 @@
"MSG": "Bạn có muốn cài đặt Super Productivity dưới dạng PWA (Ứng dụng web tiến bộ) không?"
},
"B_OFFLINE": "Bạn đang ngoại tuyến. Đồng bộ và yêu cầu dữ liệu từ nhà cung cấp sự cố sẽ không hoạt động.",
"B_SYNC_SAFETY": {
"DISMISS": "Để sau",
"MSG": "Bạn đã sử dụng Super Productivity một thời gian. Hãy thiết lập đồng bộ hóa để sao lưu dữ liệu của bạn và giữ an toàn trên mọi thiết bị.",
"SETUP": "Thiết lập đồng bộ hóa"
},
"CONTEXT_MENU": {
"CHANGE_PROJECT_SETTINGS": "Thay đổi cài đặt dự án",
"CHANGE_TAG_SETTINGS": "Thay đổi cài đặt nhãn"

View file

@ -6,6 +6,11 @@
"MSG": "您想將 Super Productivity 安裝為 PWA 嗎?"
},
"B_OFFLINE": "您已斷開與網際網路的連接。同步和資料請求將失效。",
"B_SYNC_SAFETY": {
"DISMISS": "暫不",
"MSG": "您已經使用 Super Productivity 一段時間了。設定同步以備份您的資料,並在所有裝置上保持安全。",
"SETUP": "設定同步"
},
"CONTEXT_MENU": {
"CHANGE_BACKGROUND": "更改背景",
"CHANGE_PROJECT_SETTINGS": "變更專案設定",

View file

@ -6,6 +6,11 @@
"MSG": "您想要将超级生产力安装为 PWA 吗?"
},
"B_OFFLINE": "您已断开互联网连接。同步和请求问题提供者数据将无法工作。",
"B_SYNC_SAFETY": {
"DISMISS": "暂不",
"MSG": "您已经使用 Super Productivity 一段时间了。设置同步以备份您的数据,并在所有设备上保持安全。",
"SETUP": "设置同步"
},
"CONTEXT_MENU": {
"CHANGE_BACKGROUND": "更改背景",
"CHANGE_PROJECT_SETTINGS": "更改项目设置",