From 65cd605b739ae0d213b3502308e9cd523d3e1ae7 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Sat, 21 Jan 2023 13:14:55 +0900
Subject: [PATCH] Achievements (#9665)

* wip

* Update ja-JP.yml

* wip

* wip

* Update MkAchievements.vue

* wip

* :art:

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip
---
 locales/ja-JP.yml                             | 217 +++++++++
 .../migration/1674118260469-achievement.js    |  33 ++
 .../migration/1674255666603-loggedInDates.js  |  11 +
 .../backend/src/core/AchievementService.ts    | 114 +++++
 packages/backend/src/core/CoreModule.ts       |   6 +
 .../entities/NotificationEntityService.ts     |   3 +
 .../src/core/entities/UserEntityService.ts    |   7 +-
 .../src/models/entities/Notification.ts       |   6 +
 .../src/models/entities/UserProfile.ts        |  13 +
 .../backend/src/server/api/EndpointsModule.ts |   8 +
 packages/backend/src/server/api/endpoints.ts  |   4 +
 .../api/endpoints/drive/folders/update.ts     |   4 +-
 .../backend/src/server/api/endpoints/i.ts     |  27 +-
 .../api/endpoints/i/claim-achievement.ts      |  28 ++
 .../api/endpoints/users/achievements.ts       |  31 ++
 packages/backend/src/types.ts                 |   2 +-
 packages/frontend/src/account.ts              |   7 +-
 .../src/components/MkAchievements.vue         | 224 +++++++++
 .../frontend/src/components/MkClickerGame.vue |  11 +-
 .../src/components/MkDrive.folder.vue         |   7 +-
 packages/frontend/src/components/MkDrive.vue  |   7 +-
 .../src/components/MkFollowButton.vue         |  17 +
 packages/frontend/src/components/MkNote.vue   |   4 +
 .../src/components/MkNoteDetailed.vue         |   4 +
 .../src/components/MkNotification.vue         |  13 +
 .../frontend/src/components/MkPostForm.vue    |  31 +-
 .../components/MkReactionsViewer.reaction.vue |   4 +
 packages/frontend/src/init.ts                 |  77 ++++
 packages/frontend/src/navbar.ts               |   8 +-
 packages/frontend/src/pages/achievements.vue  |  25 ++
 .../frontend/src/pages/settings/profile.vue   |   9 +
 packages/frontend/src/router.ts               |   4 +
 packages/frontend/src/scripts/achievements.ts | 425 ++++++++++++++++++
 .../frontend/src/scripts/get-note-menu.ts     |  12 +
 34 files changed, 1385 insertions(+), 18 deletions(-)
 create mode 100644 packages/backend/migration/1674118260469-achievement.js
 create mode 100644 packages/backend/migration/1674255666603-loggedInDates.js
 create mode 100644 packages/backend/src/core/AchievementService.ts
 create mode 100644 packages/backend/src/server/api/endpoints/i/claim-achievement.ts
 create mode 100644 packages/backend/src/server/api/endpoints/users/achievements.ts
 create mode 100644 packages/frontend/src/components/MkAchievements.vue
 create mode 100644 packages/frontend/src/pages/achievements.vue
 create mode 100644 packages/frontend/src/scripts/achievements.ts

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 5721fcc887..e46e6ab9de 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -938,6 +938,222 @@ cannotPerformTemporary: "一時的に利用できません"
 cannotPerformTemporaryDescription: "操作回数が制限を超過するため一時的に利用できません。しばらく時間を置いてから再度お試しください。"
 preset: "プリセット"
 selectFromPresets: "プリセットから選択"
+achievements: "実績"
+
+_achievements:
+  earnedAt: "獲得日時"
+  _types:
+    _notes1:
+      title: "just setting up my msky"
+      description: "初めてノートを投稿した"
+      flavor: "良いMisskeyライフを!"
+    _notes10:
+      title: "いくつかのノート"
+      description: "ノートを10回投稿した"
+    _notes100:
+      title: "たくさんのノート"
+      description: "ノートを100回投稿した"
+    _notes500:
+      title: "ノートまみれ"
+      description: "ノートを500回投稿した"
+    _notes1000:
+      title: "ノートの山"
+      description: "ノートを1,000回投稿した"
+    _notes5000:
+      title: "湧き出るノート"
+      description: "ノートを5,000回投稿した"
+    _notes10000:
+      title: "スーパーノート"
+      description: "ノートを10,000回投稿した"
+    _notes20000:
+      title: "ニードモアノート"
+      description: "ノートを20,000回投稿した"
+    _notes30000:
+      title: "ノートノートノート"
+      description: "ノートを30,000回投稿した"
+    _notes40000:
+      title: "ノート工場"
+      description: "ノートを40,000回投稿した"
+    _notes50000:
+      title: "ノートの惑星"
+      description: "ノートを50,000回投稿した"
+    _notes60000:
+      title: "ノートクエーサー"
+      description: "ノートを60,000回投稿した"
+    _notes70000:
+      title: "ブラックノートホール"
+      description: "ノートを70,000回投稿した"
+    _notes80000:
+      title: "ノートギャラクシー"
+      description: "ノートを80,000回投稿した"
+    _notes90000:
+      title: "ノートバース"
+      description: "ノートを90,000回投稿した"
+    _notes100000:
+      title: "ALL YOUR NOTE ARE BELONG TO US"
+      description: "ノートを100,000回投稿した"
+      flavor: "そんなに書くことある?"
+    _login3:
+      title: "ビギナーⅠ"
+      description: "通算ログイン日数が3日"
+      flavor: "今日からね僕は ミスキストってことで"
+    _login7:
+      title: "ビギナーⅡ"
+      description: "通算ログイン日数が7日"
+      flavor: "慣れてきましたか?"
+    _login15:
+      title: "ビギナーⅢ"
+      description: "通算ログイン日数が15日"
+    _login30:
+      title: "ミスキストⅠ"
+      description: "通算ログイン日数が30日"
+    _login60:
+      title: "ミスキストⅡ"
+      description: "通算ログイン日数が60日"
+    _login100:
+      title: "ミスキストⅢ"
+      description: "通算ログイン日数が100日"
+      flavor: "そのユーザー、ミスキストにつき"
+    _login200:
+      title: "常連Ⅰ"
+      description: "通算ログイン日数が200日"
+    _login300:
+      title: "常連Ⅱ"
+      description: "通算ログイン日数が300日"
+    _login400:
+      title: "常連Ⅲ"
+      description: "通算ログイン日数が400日"
+    _login500:
+      title: "ベテランⅠ"
+      description: "通算ログイン日数が500日"
+      flavor: "諸君、私はノートが好きだ"
+    _login600:
+      title: "ベテランⅡ"
+      description: "通算ログイン日数が600日"
+    _login700:
+      title: "ベテランⅢ"
+      description: "通算ログイン日数が700日"
+    _login800:
+      title: "ノートマスターⅠ"
+      description: "通算ログイン日数が800日"
+    _login900:
+      title: "ノートマスターⅡ"
+      description: "通算ログイン日数が900日"
+    _login1000:
+      title: "ノートマスターⅢ"
+      description: "通算ログイン日数が1,000日"
+      flavor: "Misskeyを使ってくれてありがとう!"
+    _noteClipped1:
+      title: "クリップせずにはいられないな"
+      description: "初めてノートをクリップした"
+    _noteFavorited1:
+      title: "星をみるひと"
+      description: "初めてノートをお気に入りに登録した"
+    _profileFilled:
+      title: "準備万端"
+      description: "プロフィール設定を行った"
+    _markedAsCat:
+      title: "吾輩は猫である"
+      description: "アカウントをCatとして設定した"
+      flavor: "名前はまだない。"
+    _following1:
+      title: "はじめてのフォロー"
+      description: "初めてフォローした"
+    _following10:
+      title: "ついてく、ついてく"
+      description: "フォローが10人を超した"
+    _following50:
+      title: "友達たくさん"
+      description: "フォローが50人を超した"
+    _following100:
+      title: "友達100人"
+      description: "フォローが100人を超した"
+    _following300:
+      title: "友達過多"
+      description: "フォローが300人を超した"
+    _followers1:
+      title: "はじめてのフォロワー"
+      description: "初めてフォローされた"
+    _followers10:
+      title: "フォローミー!"
+      description: "フォロワーが10人を超した"
+    _followers50:
+      title: "ぞろぞろ"
+      description: "フォロワーが50人を超した"
+    _followers100:
+      title: "人気者"
+      description: "フォロワーが100人を超した"
+    _followers300:
+      title: "一列でお並びください"
+      description: "フォロワーが300人を超した"
+    _followers500:
+      title: "基地局"
+      description: "フォロワーが500人を超した"
+    _followers1000:
+      title: "インフルエンサー"
+      description: "フォロワーが1,000人を超した"
+    _collectAchievements30:
+      title: "実績コレクター"
+      description: "実績を30個以上獲得した"
+    _iLoveMisskey:
+      title: "I Love Misskey"
+      description: "\"I ❤ #Misskey\"を投稿した"
+      flavor: "Misskeyを使ってくださりありがとうございます! by 開発チーム"
+    _client30min:
+      title: "ひとやすみ"
+      description: "クライアントを起動してから30分以上経過した"
+    _noteDeletedWithin1min:
+      title: "いまのなし"
+      description: "投稿してから1分以内にその投稿を削除した"
+    _postedAtLateNight:
+      title: "夜行性"
+      description: "深夜にノートを投稿した"
+      flavor: "そろそろ寝よう。"
+    _postedAt0min0sec:
+      title: "時報"
+      description: "0分0秒にノートを投稿した"
+      flavor: "ポッ ポッ ポッ ピーン"
+    _selfQuote:
+      title: "自己言及"
+      description: "自分のノートを引用した"
+    _htl20npm:
+      title: "流れるTL"
+      description: "ホームタイムラインの流速が20npmを越す"
+    _driveFolderCircularReference:
+      title: "循環参照"
+      description: "ドライブのフォルダを再帰的な入れ子にしようとした"
+    _reactWithoutRead:
+      title: "ちゃんと読んだ?"
+      description: "100文字以上のテキストを含むノートに投稿されてから3秒以内にリアクションした"
+    _clickedClickHere:
+      title: "ここをクリック"
+      description: "ここをクリックした"
+    _justPlainLucky:
+      title: "単なるラッキー"
+      description: "10秒ごとに0.01%の確率で獲得"
+    _setNameToSyuilo:
+      title: "神様コンプレックス"
+      description: "名前を syuilo に設定した"
+    _passedSinceAccountCreated1:
+      title: "一周年"
+      description: "アカウント作成から1年経過した"
+    _passedSinceAccountCreated2:
+      title: "二周年"
+      description: "アカウント作成から2年経過した"
+    _passedSinceAccountCreated3:
+      title: "三周年"
+      description: "アカウント作成から3年経過した"
+    _loggedInOnBirthday:
+      title: "ハッピーバースデー"
+      description: "誕生日にログインした"
+    _cookieClicked:
+      title: "クッキーをクリックするゲーム"
+      description: "クッキーをクリックした"
+      flavor: "ソフト間違ってない?"
+    _brainDiver:
+      title: "Brain Diver"
+      description: "Brain Diverへのリンクを投稿した"
+      flavor: "Misskey-Misskey La-Tu-Ma"
 
 _role:
   new: "ロールの作成"
@@ -1635,6 +1851,7 @@ _notification:
   pollEnded: "アンケートの結果が出ました"
   unreadAntennaNote: "アンテナ {name}"
   emptyPushNotificationMessage: "プッシュ通知の更新をしました"
+  achievementEarned: "実績を獲得"
 
   _types:
     all: "すべて"
diff --git a/packages/backend/migration/1674118260469-achievement.js b/packages/backend/migration/1674118260469-achievement.js
new file mode 100644
index 0000000000..131ab96f80
--- /dev/null
+++ b/packages/backend/migration/1674118260469-achievement.js
@@ -0,0 +1,33 @@
+export class achievement1674118260469 {
+    name = 'achievement1674118260469'
+
+    async up(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "notification" ADD "achievement" character varying(128)`);
+        await queryRunner.query(`ALTER TABLE "user_profile" ADD "achievements" jsonb NOT NULL DEFAULT '[]'`);
+        await queryRunner.query(`ALTER TYPE "public"."notification_type_enum" RENAME TO "notification_type_enum_old"`);
+        await queryRunner.query(`CREATE TYPE "public"."notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app')`);
+        await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum" USING "type"::"text"::"public"."notification_type_enum"`);
+        await queryRunner.query(`DROP TYPE "public"."notification_type_enum_old"`);
+        await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum" RENAME TO "user_profile_mutingnotificationtypes_enum_old"`);
+        await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app')`);
+        await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`);
+        await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum"[]`);
+        await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`);
+        await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum_old"`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'pollEnded')`);
+        await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`);
+        await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum_old"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum_old"[]`);
+        await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`);
+        await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum"`);
+        await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum_old" RENAME TO "user_profile_mutingnotificationtypes_enum"`);
+        await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`);
+        await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum_old" USING "type"::"text"::"public"."notification_type_enum_old"`);
+        await queryRunner.query(`DROP TYPE "public"."notification_type_enum"`);
+        await queryRunner.query(`ALTER TYPE "public"."notification_type_enum_old" RENAME TO "notification_type_enum"`);
+        await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "achievements"`);
+        await queryRunner.query(`ALTER TABLE "notification" DROP COLUMN "achievement"`);
+    }
+}
diff --git a/packages/backend/migration/1674255666603-loggedInDates.js b/packages/backend/migration/1674255666603-loggedInDates.js
new file mode 100644
index 0000000000..6d75ab6436
--- /dev/null
+++ b/packages/backend/migration/1674255666603-loggedInDates.js
@@ -0,0 +1,11 @@
+export class loggedInDates1674255666603 {
+    name = 'loggedInDates1674255666603'
+
+    async up(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "user_profile" ADD "loggedInDates" character varying(32) array NOT NULL DEFAULT '{}'`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "loggedInDates"`);
+    }
+}
diff --git a/packages/backend/src/core/AchievementService.ts b/packages/backend/src/core/AchievementService.ts
new file mode 100644
index 0000000000..4ed75308eb
--- /dev/null
+++ b/packages/backend/src/core/AchievementService.ts
@@ -0,0 +1,114 @@
+import { Inject, Injectable } from '@nestjs/common';
+import type { UserProfilesRepository, UsersRepository } from '@/models/index.js';
+import type { User } from '@/models/entities/User.js';
+import { DI } from '@/di-symbols.js';
+import { bindThis } from '@/decorators.js';
+import { CreateNotificationService } from '@/core/CreateNotificationService.js';
+
+const ACHIEVEMENT_TYPES = [
+	'notes1',
+	'notes10',
+	'notes100',
+	'notes500',
+	'notes1000',
+	'notes5000',
+	'notes10000',
+	'notes20000',
+	'notes30000',
+	'notes40000',
+	'notes50000',
+	'notes60000',
+	'notes70000',
+	'notes80000',
+	'notes90000',
+	'notes100000',
+	'login3',
+	'login7',
+	'login15',
+	'login30',
+	'login60',
+	'login100',
+	'login200',
+	'login300',
+	'login400',
+	'login500',
+	'login600',
+	'login700',
+	'login800',
+	'login900',
+	'login1000',
+	'passedSinceAccountCreated1',
+	'passedSinceAccountCreated2',
+	'passedSinceAccountCreated3',
+	'loggedInOnBirthday',
+	'noteClipped1',
+	'noteFavorited1',
+	'profileFilled',
+	'markedAsCat',
+	'following1',
+	'following10',
+	'following50',
+	'following100',
+	'following300',
+	'followers1',
+	'followers10',
+	'followers50',
+	'followers100',
+	'followers300',
+	'followers500',
+	'followers1000',
+	'collectAchievements30',
+	'iLoveMisskey',
+	'client30min',
+	'noteDeletedWithin1min',
+	'postedAtLateNight',
+	'postedAt0min0sec',
+	'selfQuote',
+	'htl20npm',
+	'driveFolderCircularReference',
+	'reactWithoutRead',
+	'clickedClickHere',
+	'justPlainLucky',
+	'setNameToSyuilo',
+	'cookieClicked',
+	'brainDiver',
+] as const;
+
+@Injectable()
+export class AchievementService {
+	constructor(
+		@Inject(DI.usersRepository)
+		private usersRepository: UsersRepository,
+
+		@Inject(DI.userProfilesRepository)
+		private userProfilesRepository: UserProfilesRepository,
+
+		private createNotificationService: CreateNotificationService,
+	) {
+	}
+
+	@bindThis
+	public async create(
+		userId: User['id'],
+		type: string,
+	): Promise<void> {
+		if (!ACHIEVEMENT_TYPES.includes(type)) return;
+
+		const date = Date.now();
+
+		const profile = await this.userProfilesRepository.findOneByOrFail({ userId: userId });
+
+		if (profile.achievements.some(a => a.name === type)) return;
+
+		await this.userProfilesRepository.update(userId, {
+			achievements: [...profile.achievements, {
+				name: type,
+				unlockedAt: date,
+			}],
+		});
+
+		this.createNotificationService.createNotification(userId, 'achievementEarned', {
+			achievement: type,
+		});
+	}
+}
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index 0ae1ee32b2..eddf407940 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -4,6 +4,7 @@ import { AccountUpdateService } from './AccountUpdateService.js';
 import { AiService } from './AiService.js';
 import { AntennaService } from './AntennaService.js';
 import { AppLockService } from './AppLockService.js';
+import { AchievementService } from './AchievementService.js';
 import { CaptchaService } from './CaptchaService.js';
 import { CreateNotificationService } from './CreateNotificationService.js';
 import { CreateSystemUserService } from './CreateSystemUserService.js';
@@ -128,6 +129,7 @@ const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useEx
 const $AiService: Provider = { provide: 'AiService', useExisting: AiService };
 const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService };
 const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService };
+const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService };
 const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService };
 const $CreateNotificationService: Provider = { provide: 'CreateNotificationService', useExisting: CreateNotificationService };
 const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService };
@@ -255,6 +257,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		AiService,
 		AntennaService,
 		AppLockService,
+		AchievementService,
 		CaptchaService,
 		CreateNotificationService,
 		CreateSystemUserService,
@@ -376,6 +379,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$AiService,
 		$AntennaService,
 		$AppLockService,
+		$AchievementService,
 		$CaptchaService,
 		$CreateNotificationService,
 		$CreateSystemUserService,
@@ -498,6 +502,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		AiService,
 		AntennaService,
 		AppLockService,
+		AchievementService,
 		CaptchaService,
 		CreateNotificationService,
 		CreateSystemUserService,
@@ -618,6 +623,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$AiService,
 		$AntennaService,
 		$AppLockService,
+		$AchievementService,
 		$CaptchaService,
 		$CreateNotificationService,
 		$CreateSystemUserService,
diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts
index a1c2c9cffb..a8210eea02 100644
--- a/packages/backend/src/core/entities/NotificationEntityService.ts
+++ b/packages/backend/src/core/entities/NotificationEntityService.ts
@@ -114,6 +114,9 @@ export class NotificationEntityService implements OnModuleInit {
 			...(notification.type === 'groupInvited' ? {
 				invitation: this.userGroupInvitationEntityService.pack(notification.userGroupInvitationId!),
 			} : {}),
+			...(notification.type === 'achievementEarned' ? {
+				achievement: notification.achievement,
+			} : {}),
 			...(notification.type === 'app' ? {
 				body: notification.customBody,
 				header: notification.customHeader ?? token?.name,
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index bf6f6f4553..34b523e143 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -12,7 +12,7 @@ import { Cache } from '@/misc/cache.js';
 import type { Instance } from '@/models/entities/Instance.js';
 import type { ILocalUser, IRemoteUser, User } from '@/models/entities/User.js';
 import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js';
-import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, MessagingMessagesRepository, UserGroupJoiningsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository } from '@/models/index.js';
+import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, MessagingMessagesRepository, UserGroupJoiningsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository, UserProfile } from '@/models/index.js';
 import { bindThis } from '@/decorators.js';
 import { RoleService } from '@/core/RoleService.js';
 import type { OnModuleInit } from '@nestjs/common';
@@ -343,6 +343,7 @@ export class UserEntityService implements OnModuleInit {
 		options?: {
 			detail?: D,
 			includeSecrets?: boolean,
+			userProfile?: UserProfile,
 		},
 	): Promise<IsMeAndIsUserDetailed<ExpectsMe, D>> {
 		const opts = Object.assign({
@@ -375,7 +376,7 @@ export class UserEntityService implements OnModuleInit {
 			.innerJoinAndSelect('pin.note', 'note')
 			.orderBy('pin.id', 'DESC')
 			.getMany() : [];
-		const profile = opts.detail ? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null;
+		const profile = opts.detail ? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) : null;
 
 		const followingCount = profile == null ? null :
 			(profile.ffVisibility === 'public') || isMe ? user.followingCount :
@@ -493,6 +494,8 @@ export class UserEntityService implements OnModuleInit {
 				mutingNotificationTypes: profile!.mutingNotificationTypes,
 				emailNotificationTypes: profile!.emailNotificationTypes,
 				showTimelineReplies: user.showTimelineReplies ?? falsy,
+				achievements: profile!.achievements,
+				loggedInDays: profile!.loggedInDates.length,
 			} : {}),
 
 			...(opts.includeSecrets ? {
diff --git a/packages/backend/src/models/entities/Notification.ts b/packages/backend/src/models/entities/Notification.ts
index 6679cdb809..66f131d1c0 100644
--- a/packages/backend/src/models/entities/Notification.ts
+++ b/packages/backend/src/models/entities/Notification.ts
@@ -64,6 +64,7 @@ export class Notification {
 	 * receiveFollowRequest - フォローリクエストされた
 	 * followRequestAccepted - 自分の送ったフォローリクエストが承認された
 	 * groupInvited - グループに招待された
+	 * achievementEarned - 実績を獲得
 	 * app - アプリ通知
 	 */
 	@Index()
@@ -129,6 +130,11 @@ export class Notification {
 	})
 	public choice: number | null;
 
+	@Column('varchar', {
+		length: 128, nullable: true,
+	})
+	public achievement: string | null;
+
 	/**
 	 * アプリ通知のbody
 	 */
diff --git a/packages/backend/src/models/entities/UserProfile.ts b/packages/backend/src/models/entities/UserProfile.ts
index c561da87ce..86df8d5d98 100644
--- a/packages/backend/src/models/entities/UserProfile.ts
+++ b/packages/backend/src/models/entities/UserProfile.ts
@@ -213,6 +213,19 @@ export class UserProfile {
 	})
 	public mutingNotificationTypes: typeof notificationTypes[number][];
 
+	@Column('varchar', {
+		length: 32, array: true, default: '{}',
+	})
+	public loggedInDates: string[];
+
+	@Column('jsonb', {
+		default: [],
+	})
+	public achievements: {
+		name: string;
+		unlockedAt: number;
+	}[];
+
 	//#region Denormalized fields
 	@Index()
 	@Column('varchar', {
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index 14927da7d6..466651f379 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -175,6 +175,7 @@ import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js';
 import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js';
 import * as ep___i_apps from './endpoints/i/apps.js';
 import * as ep___i_authorizedApps from './endpoints/i/authorized-apps.js';
+import * as ep___i_claimAchievement from './endpoints/i/claim-achievement.js';
 import * as ep___i_changePassword from './endpoints/i/change-password.js';
 import * as ep___i_deleteAccount from './endpoints/i/delete-account.js';
 import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js';
@@ -329,6 +330,7 @@ import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by
 import * as ep___users_search from './endpoints/users/search.js';
 import * as ep___users_show from './endpoints/users/show.js';
 import * as ep___users_stats from './endpoints/users/stats.js';
+import * as ep___users_achievements from './endpoints/users/achievements.js';
 import * as ep___fetchRss from './endpoints/fetch-rss.js';
 import * as ep___retention from './endpoints/retention.js';
 import { GetterService } from './GetterService.js';
@@ -509,6 +511,7 @@ const $i_2fa_removeKey: Provider = { provide: 'ep:i/2fa/remove-key', useClass: e
 const $i_2fa_unregister: Provider = { provide: 'ep:i/2fa/unregister', useClass: ep___i_2fa_unregister.default };
 const $i_apps: Provider = { provide: 'ep:i/apps', useClass: ep___i_apps.default };
 const $i_authorizedApps: Provider = { provide: 'ep:i/authorized-apps', useClass: ep___i_authorizedApps.default };
+const $i_claimAchievement: Provider = { provide: 'ep:i/claim-achievement', useClass: ep___i_claimAchievement.default };
 const $i_changePassword: Provider = { provide: 'ep:i/change-password', useClass: ep___i_changePassword.default };
 const $i_deleteAccount: Provider = { provide: 'ep:i/delete-account', useClass: ep___i_deleteAccount.default };
 const $i_exportBlocking: Provider = { provide: 'ep:i/export-blocking', useClass: ep___i_exportBlocking.default };
@@ -663,6 +666,7 @@ const $users_searchByUsernameAndHost: Provider = { provide: 'ep:users/search-by-
 const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___users_search.default };
 const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default };
 const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.default };
+const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default };
 const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default };
 const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default };
 
@@ -847,6 +851,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$i_2fa_unregister,
 		$i_apps,
 		$i_authorizedApps,
+		$i_claimAchievement,
 		$i_changePassword,
 		$i_deleteAccount,
 		$i_exportBlocking,
@@ -1001,6 +1006,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$users_search,
 		$users_show,
 		$users_stats,
+		$users_achievements,
 		$fetchRss,
 		$retention,
 	],
@@ -1179,6 +1185,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$i_2fa_unregister,
 		$i_apps,
 		$i_authorizedApps,
+		$i_claimAchievement,
 		$i_changePassword,
 		$i_deleteAccount,
 		$i_exportBlocking,
@@ -1331,6 +1338,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$users_search,
 		$users_show,
 		$users_stats,
+		$users_achievements,
 		$fetchRss,
 		$retention,
 	],
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 54c4206ea4..3678fe14e8 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -174,6 +174,7 @@ import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js';
 import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js';
 import * as ep___i_apps from './endpoints/i/apps.js';
 import * as ep___i_authorizedApps from './endpoints/i/authorized-apps.js';
+import * as ep___i_claimAchievement from './endpoints/i/claim-achievement.js';
 import * as ep___i_changePassword from './endpoints/i/change-password.js';
 import * as ep___i_deleteAccount from './endpoints/i/delete-account.js';
 import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js';
@@ -328,6 +329,7 @@ import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by
 import * as ep___users_search from './endpoints/users/search.js';
 import * as ep___users_show from './endpoints/users/show.js';
 import * as ep___users_stats from './endpoints/users/stats.js';
+import * as ep___users_achievements from './endpoints/users/achievements.js';
 import * as ep___fetchRss from './endpoints/fetch-rss.js';
 import * as ep___retention from './endpoints/retention.js';
 
@@ -506,6 +508,7 @@ const eps = [
 	['i/2fa/unregister', ep___i_2fa_unregister],
 	['i/apps', ep___i_apps],
 	['i/authorized-apps', ep___i_authorizedApps],
+	['i/claim-achievement', ep___i_claimAchievement],
 	['i/change-password', ep___i_changePassword],
 	['i/delete-account', ep___i_deleteAccount],
 	['i/export-blocking', ep___i_exportBlocking],
@@ -660,6 +663,7 @@ const eps = [
 	['users/search', ep___users_search],
 	['users/show', ep___users_show],
 	['users/stats', ep___users_stats],
+	['users/achievements', ep___users_achievements],
 	['fetch-rss', ep___fetchRss],
 	['retention', ep___retention],
 ];
diff --git a/packages/backend/src/server/api/endpoints/drive/folders/update.ts b/packages/backend/src/server/api/endpoints/drive/folders/update.ts
index ee63d291b2..ff0a78b929 100644
--- a/packages/backend/src/server/api/endpoints/drive/folders/update.ts
+++ b/packages/backend/src/server/api/endpoints/drive/folders/update.ts
@@ -28,8 +28,8 @@ export const meta = {
 
 		recursiveNesting: {
 			message: 'It can not be structured like nesting folders recursively.',
-			code: 'NO_SUCH_PARENT_FOLDER',
-			id: 'ce104e3a-faaf-49d5-b459-10ff0cbbcaa1',
+			code: 'RECURSIVE_NESTING',
+			id: 'dbeb024837894013aed44279f9199740',
 		},
 	},
 
diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts
index 3bcd6ff8fb..6beef5ab85 100644
--- a/packages/backend/src/server/api/endpoints/i.ts
+++ b/packages/backend/src/server/api/endpoints/i.ts
@@ -1,5 +1,5 @@
 import { Inject, Injectable } from '@nestjs/common';
-import type { UsersRepository } from '@/models/index.js';
+import type { UserProfilesRepository, UsersRepository } from '@/models/index.js';
 import { Endpoint } from '@/server/api/endpoint-base.js';
 import { UserEntityService } from '@/core/entities/UserEntityService.js';
 import { DI } from '@/di-symbols.js';
@@ -29,15 +29,36 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
 		@Inject(DI.usersRepository)
 		private usersRepository: UsersRepository,
 
+		@Inject(DI.userProfilesRepository)
+		private userProfilesRepository: UserProfilesRepository,
+
 		private userEntityService: UserEntityService,
 	) {
 		super(meta, paramDef, async (ps, user, token) => {
 			const isSecure = token == null;
 
-			// ここで渡ってきている user はキャッシュされていて古い可能性もあるので id だけ渡す
-			return await this.userEntityService.pack<true, true>(user.id, user, {
+			const now = new Date();
+			const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`;
+
+			// 渡ってきている user はキャッシュされていて古い可能性があるので改めて取得
+			const userProfile = await this.userProfilesRepository.findOneOrFail({
+				where: {
+					userId: user.id,
+				},
+				relations: ['user'],
+			});
+
+			if (!userProfile.loggedInDates.includes(today)) {
+				this.userProfilesRepository.update({ userId: user.id }, {
+					loggedInDates: [...userProfile.loggedInDates, today],
+				});
+				userProfile.loggedInDates = [...userProfile.loggedInDates, today];
+			}
+			
+			return await this.userEntityService.pack<true, true>(userProfile.user!, userProfile.user!, {
 				detail: true,
 				includeSecrets: isSecure,
+				userProfile,
 			});
 		});
 	}
diff --git a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts
new file mode 100644
index 0000000000..52ae5475b6
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts
@@ -0,0 +1,28 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { AchievementService } from '@/core/AchievementService.js';
+
+export const meta = {
+	requireCredential: true,
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		name: { type: 'string' },
+	},
+	required: ['name'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		private achievementService: AchievementService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			await this.achievementService.create(me.id, ps.name);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/users/achievements.ts b/packages/backend/src/server/api/endpoints/users/achievements.ts
new file mode 100644
index 0000000000..2a095d83ea
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/users/achievements.ts
@@ -0,0 +1,31 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { UserProfilesRepository } from '@/models/index.js';
+import { DI } from '@/di-symbols.js';
+
+export const meta = {
+	requireCredential: true,
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		userId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['userId'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.userProfilesRepository)
+		private userProfilesRepository: UserProfilesRepository,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const profile = await this.userProfilesRepository.findOneByOrFail({ userId: ps.userId });
+
+			return profile.achievements;
+		});
+	}
+}
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index 573e2faf87..7e9e193362 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -1,4 +1,4 @@
-export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'] as const;
+export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app'] as const;
 
 export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;
 
diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts
index 93916ccf2f..31c125d3ae 100644
--- a/packages/frontend/src/account.ts
+++ b/packages/frontend/src/account.ts
@@ -2,11 +2,11 @@ import { defineAsyncComponent, reactive } from 'vue';
 import * as misskey from 'misskey-js';
 import { showSuspendedDialog } from './scripts/show-suspended-dialog';
 import { i18n } from './i18n';
+import { miLocalStorage } from './local-storage';
 import { del, get, set } from '@/scripts/idb-proxy';
 import { apiUrl } from '@/config';
 import { waiting, api, popup, popupMenu, success, alert } from '@/os';
 import { unisonReload, reloadChannel } from '@/scripts/unison-reload';
-import { miLocalStorage } from './local-storage';
 
 // TODO: 他のタブと永続化されたstateを同期
 
@@ -20,6 +20,11 @@ export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : n
 export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator);
 export const iAmAdmin = $i != null && $i.isAdmin;
 
+export let notesCount = $i == null ? 0 : $i.notesCount;
+export function incNotesCount() {
+	notesCount++;
+}
+
 export async function signout() {
 	waiting();
 	miLocalStorage.removeItem('account');
diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue
new file mode 100644
index 0000000000..64fea96354
--- /dev/null
+++ b/packages/frontend/src/components/MkAchievements.vue
@@ -0,0 +1,224 @@
+<template>
+<div>
+	<div v-if="achievements" :class="$style.root">
+		<div v-for="achievement in achievements" :key="achievement" :class="$style.achievement" class="_panel">
+			<div :class="$style.icon">
+				<div :class="[$style.iconFrame, $style['iconFrame_' + ACHIEVEMENT_BADGES[achievement.name].frame]]">
+					<div :class="[$style.iconInner]" :style="{ background: ACHIEVEMENT_BADGES[achievement.name].bg }">
+						<img :class="$style.iconImg" :src="ACHIEVEMENT_BADGES[achievement.name].img">
+					</div>
+				</div>
+			</div>
+			<div :class="$style.body">
+				<div :class="$style.header">
+					<span :class="$style.title">{{ i18n.ts._achievements._types['_' + achievement.name].title }}</span>
+					<span :class="$style.time">
+						<time v-tooltip="new Date(achievement.unlockedAt).toLocaleString()">{{ new Date(achievement.unlockedAt).getFullYear() }}/{{ new Date(achievement.unlockedAt).getMonth() + 1 }}/{{ new Date(achievement.unlockedAt).getDate() }}</time>
+					</span>
+				</div>
+				<div :class="$style.description">{{ i18n.ts._achievements._types['_' + achievement.name].description }}</div>
+				<div v-if="i18n.ts._achievements._types['_' + achievement.name].flavor" :class="$style.flavor">{{ i18n.ts._achievements._types['_' + achievement.name].flavor }}</div>
+			</div>
+		</div>
+		<template v-if="withLocked">
+			<div v-for="achievement in lockedAchievements" :key="achievement" :class="[$style.achievement, $style.locked]" class="_panel" @click="achievement === 'clickedClickHere' ? clickHere() : () => {}">
+				<div :class="$style.icon">
+				</div>
+				<div :class="$style.body">
+					<div :class="$style.header">
+						<span :class="$style.title">???</span>
+					</div>
+					<div :class="$style.description">???</div>
+				</div>
+			</div>
+		</template>
+	</div>
+	<div v-else>
+		<MkLoading/>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import * as misskey from 'misskey-js';
+import { onMounted } from 'vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/scripts/achievements';
+
+const props = withDefaults(defineProps<{
+	user: misskey.entities.User;
+	withLocked: boolean;
+}>(), {
+	withLocked: true,
+});
+
+let achievements = $ref();
+const lockedAchievements = $computed(() => ACHIEVEMENT_TYPES.filter(x => !(achievements ?? []).some(a => a.name === x)));
+
+function fetch() {
+	os.api('users/achievements', { userId: props.user.id }).then(res => {
+		achievements = [];
+		for (const t of ACHIEVEMENT_TYPES) {
+			const a = res.find(x => x.name === t);
+			if (a) achievements.push(a);
+		}
+		//achievements = res.sort((a, b) => b.unlockedAt - a.unlockedAt);
+	});
+}
+
+function clickHere() {
+	claimAchievement('clickedClickHere');
+	fetch();
+}
+
+onMounted(() => {
+	fetch();
+});
+</script>
+
+<style lang="scss" module>
+.root {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, min(380px, 100%));
+	grid-gap: 12px;
+	place-content: center;
+}
+
+.achievement {
+	display: flex;
+	padding: 16px;
+
+	&.locked {
+		opacity: 0.5;
+	}
+}
+
+.icon {
+	flex-shrink: 0;
+	margin-right: 12px;
+}
+
+@keyframes shine {
+	0% { translate: -30px; }
+	100% { translate: -130px; }
+}
+
+.iconFrame {
+	width: 58px;
+	height: 58px;
+	padding: 6px;
+	border-radius: 100%;
+	box-sizing: border-box;
+	pointer-events: none;
+	user-select: none;
+	filter: drop-shadow(0px 2px 2px #00000044);
+	box-shadow: 0 1px 0px #ffffff88 inset;
+	overflow: clip;
+}
+.iconFrame_bronze {
+	background: linear-gradient(0deg, #703827, #d37566);
+
+	> .iconInner {
+		background: linear-gradient(0deg, #d37566, #703827);
+	}
+}
+.iconFrame_silver {
+	background: linear-gradient(0deg, #7c7c7c, #e1e1e1);
+
+	> .iconInner {
+		background: linear-gradient(0deg, #e1e1e1, #7c7c7c);
+	}
+}
+.iconFrame_gold {
+	background: linear-gradient(0deg, rgba(255,182,85,1) 0%, rgba(233,133,0,1) 49%, rgba(255,243,93,1) 51%, rgba(255,187,25,1) 100%);
+
+	> .iconInner {
+		background: linear-gradient(0deg, #ffee20, #eb7018);
+	}
+
+	&:before {
+		content: "";
+		display: block;
+		position: absolute;
+    top: 30px;
+    width: 200px;
+    height: 8px;
+    rotate: -45deg;
+    translate: -30px;
+		background: #ffffff88;
+		animation: shine 2s infinite;
+	}
+}
+.iconFrame_platinum {
+	background: linear-gradient(0deg, rgba(154,154,154,1) 0%, rgba(226,226,226,1) 49%, rgba(255,255,255,1) 51%, rgba(195,195,195,1) 100%);
+
+	> .iconInner {
+		background: linear-gradient(0deg, #e1e1e1, #7c7c7c);
+	}
+
+	&:before {
+		content: "";
+		display: block;
+		position: absolute;
+    top: 30px;
+    width: 200px;
+    height: 8px;
+    rotate: -45deg;
+    translate: -30px;
+		background: #ffffffee;
+		animation: shine 2s infinite;
+	}
+}
+
+.iconInner {
+	position: relative;
+	width: 100%;
+	height: 100%;
+	border-radius: 100%;
+	box-shadow: 0 1px 0px #ffffff88 inset;
+}
+
+.iconImg {
+	width: calc(100% - 12px);
+	height: calc(100% - 12px);
+	position: absolute;
+	top: 0;
+	right: 0;
+	bottom: 0;
+	left: 0;
+	margin: auto;
+	filter: drop-shadow(0px 1px 2px #000000aa);
+}
+
+.body {
+	flex: 1;
+	min-width: 0;
+}
+
+.header {
+	margin-bottom: 8px;
+	display: flex;
+}
+
+.title {
+	font-weight: bold;
+}
+
+.time {
+	margin-left: auto;
+	font-size: 85%;
+	opacity: 0.7;
+}
+
+.description {
+	font-size: 85%;
+}
+
+.flavor {
+	opacity: 0.7;
+	transform: skewX(-15deg);
+	font-size: 85%;
+	margin-top: 8px;
+}
+</style>
diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue
index 03736ac5e4..68e0f8185d 100644
--- a/packages/frontend/src/components/MkClickerGame.vue
+++ b/packages/frontend/src/components/MkClickerGame.vue
@@ -20,6 +20,7 @@ import * as os from '@/os';
 import { useInterval } from '@/scripts/use-interval';
 import * as game from '@/scripts/clicker-game';
 import number from '@/filters/number';
+import { claimAchievement } from '@/scripts/achievements';
 
 defineProps<{
 }>();
@@ -30,14 +31,18 @@ let cps = $ref(0);
 let prevCookies = $ref(0);
 
 function onClick(ev: MouseEvent) {
+	const x = ev.clientX;
+	const y = ev.clientY;
+	os.popup(MkPlusOneEffect, { x, y }, {}, 'end');
+
 	saveData.value!.cookies++;
 	saveData.value!.totalCookies++;
 	saveData.value!.totalHandmadeCookies++;
 	saveData.value!.clicked++;
 
-	const x = ev.clientX;
-	const y = ev.clientY;
-	os.popup(MkPlusOneEffect, { x, y }, {}, 'end');
+	if (cookies.value === 1) {
+		claimAchievement('cookieClicked');
+	}
 }
 
 useInterval(() => {
diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue
index 82653ca0b4..156013b9aa 100644
--- a/packages/frontend/src/components/MkDrive.folder.vue
+++ b/packages/frontend/src/components/MkDrive.folder.vue
@@ -33,6 +33,7 @@ import * as Misskey from 'misskey-js';
 import * as os from '@/os';
 import { i18n } from '@/i18n';
 import { defaultStore } from '@/store';
+import { claimAchievement } from '@/scripts/achievements';
 
 const props = withDefaults(defineProps<{
 	folder: Misskey.entities.DriveFolder;
@@ -159,9 +160,11 @@ function onDrop(ev: DragEvent) {
 		}).then(() => {
 			// noop
 		}).catch(err => {
-			switch (err) {
-				case 'detected-circular-definition':
+			switch (err.code) {
+				case 'RECURSIVE_NESTING':
+					claimAchievement('driveFolderCircularReference');
 					os.alert({
+						type: 'error',
 						title: i18n.ts.unableToProcess,
 						text: i18n.ts.circularReferenceFolder,
 					});
diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue
index 112a64f52d..af7175e5cd 100644
--- a/packages/frontend/src/components/MkDrive.vue
+++ b/packages/frontend/src/components/MkDrive.vue
@@ -99,6 +99,7 @@ import { stream } from '@/stream';
 import { defaultStore } from '@/store';
 import { i18n } from '@/i18n';
 import { uploadFile, uploads } from '@/scripts/upload';
+import { claimAchievement } from '@/scripts/achievements';
 
 const props = withDefaults(defineProps<{
 	initialFolder?: Misskey.entities.DriveFolder;
@@ -268,9 +269,11 @@ function onDrop(ev: DragEvent): any {
 		}).then(() => {
 			// noop
 		}).catch(err => {
-			switch (err) {
-				case 'detected-circular-definition':
+			switch (err.code) {
+				case 'RECURSIVE_NESTING':
+					claimAchievement('driveFolderCircularReference');
 					os.alert({
+						type: 'error',
 						title: i18n.ts.unableToProcess,
 						text: i18n.ts.circularReferenceFolder,
 					});
diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue
index ee256d9263..de8db54bfa 100644
--- a/packages/frontend/src/components/MkFollowButton.vue
+++ b/packages/frontend/src/components/MkFollowButton.vue
@@ -35,6 +35,8 @@ import * as Misskey from 'misskey-js';
 import * as os from '@/os';
 import { stream } from '@/stream';
 import { i18n } from '@/i18n';
+import { claimAchievement } from '@/scripts/achievements';
+import { $i } from '@/account';
 
 const props = withDefaults(defineProps<{
 	user: Misskey.entities.UserDetailed,
@@ -90,6 +92,21 @@ async function onClick() {
 					userId: props.user.id,
 				});
 				hasPendingFollowRequestFromYou = true;
+
+				claimAchievement('following1');
+
+				if ($i.followingCount >= 10) {
+					claimAchievement('following10');
+				}
+				if ($i.followingCount >= 50) {
+					claimAchievement('following50');
+				}
+				if ($i.followingCount >= 100) {
+					claimAchievement('following100');
+				}
+				if ($i.followingCount >= 300) {
+					claimAchievement('following300');
+				}
 			}
 		}
 	} catch (err) {
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 9b2501a2ed..1f6a2883d7 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -143,6 +143,7 @@ import { getNoteMenu } from '@/scripts/get-note-menu';
 import { useNoteCapture } from '@/scripts/use-note-capture';
 import { deepClone } from '@/scripts/clone';
 import { useTooltip } from '@/scripts/use-tooltip';
+import { claimAchievement } from '@/scripts/achievements';
 
 const props = defineProps<{
 	note: misskey.entities.Note;
@@ -268,6 +269,9 @@ function react(viaKeyboard = false): void {
 			noteId: appearNote.id,
 			reaction: reaction,
 		});
+		if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
+			claimAchievement('reactWithoutRead');
+		}
 	}, () => {
 		focus();
 	});
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index 56061e0e6f..48ace56d9c 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -159,6 +159,7 @@ import { getNoteMenu } from '@/scripts/get-note-menu';
 import { useNoteCapture } from '@/scripts/use-note-capture';
 import { deepClone } from '@/scripts/clone';
 import { useTooltip } from '@/scripts/use-tooltip';
+import { claimAchievement } from '@/scripts/achievements';
 
 const props = defineProps<{
 	note: misskey.entities.Note;
@@ -279,6 +280,9 @@ function react(viaKeyboard = false): void {
 			noteId: appearNote.id,
 			reaction: reaction,
 		});
+		if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) {
+			claimAchievement('reactWithoutRead');
+		}
 	}, () => {
 		focus();
 	});
diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
index 5b8041c1d4..e992495a78 100644
--- a/packages/frontend/src/components/MkNotification.vue
+++ b/packages/frontend/src/components/MkNotification.vue
@@ -2,6 +2,7 @@
 <div ref="elRef" :class="$style.root">
 	<div v-once :class="$style.head">
 		<MkAvatar v-if="notification.type === 'pollEnded'" :class="$style.icon" :user="notification.note.user" link preview/>
+		<MkAvatar v-else-if="notification.type === 'achievementEarned'" :class="$style.icon" :user="$i" link preview/>
 		<MkAvatar v-else-if="notification.user" :class="$style.icon" :user="notification.user" link preview/>
 		<img v-else-if="notification.icon" :class="$style.icon" :src="notification.icon" alt=""/>
 		<div :class="[$style.subIcon, $style['t_' + notification.type]]">
@@ -14,6 +15,7 @@
 			<i v-else-if="notification.type === 'mention'" class="ti ti-at"></i>
 			<i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i>
 			<i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i>
+			<i v-else-if="notification.type === 'achievementEarned'" class="ti ti-military-award"></i>
 			<!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
 			<MkReactionIcon
 				v-else-if="notification.type === 'reaction'"
@@ -28,6 +30,7 @@
 	<div :class="$style.tail">
 		<header :class="$style.header">
 			<span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span>
+			<span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span>
 			<MkA v-else-if="notification.user" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
 			<span v-else>{{ notification.header }}</span>
 			<MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/>
@@ -57,6 +60,9 @@
 				<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :author="notification.note.user"/>
 				<i class="ti ti-quote" :class="$style.quote"></i>
 			</MkA>
+			<MkA v-else-if="notification.type === 'achievementEarned'" :class="$style.text" to="/my/achievements">
+				{{ i18n.ts._achievements._types['_' + notification.achievement].title }}
+			</MkA>
 			<span v-else-if="notification.type === 'follow'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span>
 			<span v-else-if="notification.type === 'followRequestAccepted'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</span>
 			<span v-else-if="notification.type === 'receiveFollowRequest'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ i18n.ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ i18n.ts.reject }}</button></div></span>
@@ -82,6 +88,7 @@ import { i18n } from '@/i18n';
 import * as os from '@/os';
 import { stream } from '@/stream';
 import { useTooltip } from '@/scripts/use-tooltip';
+import { $i } from '@/account';
 
 const props = withDefaults(defineProps<{
 	notification: misskey.entities.Notification;
@@ -240,6 +247,12 @@ useTooltip(reactionRef, (showing) => {
 	pointer-events: none;
 }
 
+.t_achievementEarned {
+	padding: 3px;
+	background: #88a6b7;
+	pointer-events: none;
+}
+
 .tail {
 	flex: 1;
 	min-width: 0;
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 6822caf4f4..c7e7e85b2e 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -93,11 +93,12 @@ import { defaultStore, notePostInterruptors, postFormActions } from '@/store';
 import MkInfo from '@/components/MkInfo.vue';
 import { i18n } from '@/i18n';
 import { instance } from '@/instance';
-import { $i, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account';
+import { $i, notesCount, incNotesCount, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account';
 import { uploadFile } from '@/scripts/upload';
 import { deepClone } from '@/scripts/clone';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
 import { miLocalStorage } from '@/local-storage';
+import { claimAchievement } from '@/scripts/achievements';
 
 const modal = inject('modal');
 
@@ -627,6 +628,34 @@ async function post(ev?: MouseEvent) {
 			}
 			posting = false;
 			postAccount = null;
+
+			incNotesCount();
+			if (notesCount === 1) {
+				claimAchievement('notes1');
+			}
+
+			const text = postData.text?.toLowerCase() ?? '';
+			if ((text.includes('love') || text.includes('❤')) && text.includes('misskey')) {
+				claimAchievement('iLoveMisskey');
+			}
+			if (text.includes('Efrlqw8ytg4'.toLowerCase()) || text.includes('XVCwzwxdHuA'.toLowerCase())) {
+				claimAchievement('brainDiver');
+			}
+
+			if (props.renote && (props.renote.userId === $i.id) && text.length > 0) {
+				claimAchievement('selfQuote');
+			}
+
+			const date = new Date();
+			const h = date.getHours();
+			const m = date.getMinutes();
+			const s = date.getSeconds();
+			if (h >= 0 && h <= 3) {
+				claimAchievement('postedAtLateNight');
+			}
+			if (m === 0 && s === 0) {
+				claimAchievement('postedAt0min0sec');
+			}
 		});
 	}).catch(err => {
 		posting = false;
diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
index e90dd7ea69..ec4042d18c 100644
--- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue
+++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue
@@ -20,6 +20,7 @@ import * as os from '@/os';
 import { useTooltip } from '@/scripts/use-tooltip';
 import { $i } from '@/account';
 import MkReactionEffect from '@/components/MkReactionEffect.vue';
+import { claimAchievement } from '@/scripts/achievements';
 
 const props = defineProps<{
 	reaction: string;
@@ -52,6 +53,9 @@ const toggleReaction = () => {
 			noteId: props.note.id,
 			reaction: props.reaction,
 		});
+		if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) {
+			claimAchievement('reactWithoutRead');
+		}
 	}
 };
 
diff --git a/packages/frontend/src/init.ts b/packages/frontend/src/init.ts
index e10315e1ad..a2723d479c 100644
--- a/packages/frontend/src/init.ts
+++ b/packages/frontend/src/init.ts
@@ -44,6 +44,7 @@ import { reactionPicker } from '@/scripts/reaction-picker';
 import { getUrlWithoutLoginId } from '@/scripts/login-id';
 import { getAccountFromId } from '@/scripts/get-account-from-id';
 import { miLocalStorage } from './local-storage';
+import { claimAchievement, claimedAchievements } from './scripts/achievements';
 
 (async () => {
 	console.info(`Misskey v${version}`);
@@ -345,6 +346,82 @@ import { miLocalStorage } from './local-storage';
 			});
 		}
 
+		if ($i.birthday) {
+			const now = new Date();
+			const m = now.getMonth() + 1;
+			const d = now.getDate();
+			const bm = parseInt($i.birthday.split('-')[1]);
+			const bd = parseInt($i.birthday.split('-')[2]);
+			if (m === bm && d === bd) {
+				claimAchievement('loggedInOnBirthday');
+			}
+		}
+
+		if ($i.loggedInDays >= 3) claimAchievement('login3');
+		if ($i.loggedInDays >= 7) claimAchievement('login7');
+		if ($i.loggedInDays >= 15) claimAchievement('login15');
+		if ($i.loggedInDays >= 30) claimAchievement('login30');
+		if ($i.loggedInDays >= 60) claimAchievement('login60');
+		if ($i.loggedInDays >= 100) claimAchievement('login100');
+		if ($i.loggedInDays >= 200) claimAchievement('login200');
+		if ($i.loggedInDays >= 300) claimAchievement('login300');
+		if ($i.loggedInDays >= 400) claimAchievement('login400');
+		if ($i.loggedInDays >= 500) claimAchievement('login500');
+		if ($i.loggedInDays >= 600) claimAchievement('login600');
+		if ($i.loggedInDays >= 700) claimAchievement('login700');
+		if ($i.loggedInDays >= 800) claimAchievement('login800');
+		if ($i.loggedInDays >= 900) claimAchievement('login900');
+		if ($i.loggedInDays >= 1000) claimAchievement('login1000');
+
+		if ($i.notesCount > 0) claimAchievement('notes1');
+		if ($i.notesCount >= 10) claimAchievement('notes10');
+		if ($i.notesCount >= 100) claimAchievement('notes100');
+		if ($i.notesCount >= 500) claimAchievement('notes500');
+		if ($i.notesCount >= 1000) claimAchievement('notes1000');
+		if ($i.notesCount >= 5000) claimAchievement('notes5000');
+		if ($i.notesCount >= 10000) claimAchievement('notes10000');
+		if ($i.notesCount >= 20000) claimAchievement('notes20000');
+		if ($i.notesCount >= 30000) claimAchievement('notes30000');
+		if ($i.notesCount >= 40000) claimAchievement('notes40000');
+		if ($i.notesCount >= 50000) claimAchievement('notes50000');
+		if ($i.notesCount >= 60000) claimAchievement('notes60000');
+		if ($i.notesCount >= 70000) claimAchievement('notes70000');
+		if ($i.notesCount >= 80000) claimAchievement('notes80000');
+		if ($i.notesCount >= 90000) claimAchievement('notes90000');
+		if ($i.notesCount >= 100000) claimAchievement('notes100000');
+
+		if ($i.followersCount > 0) claimAchievement('followers1');
+		if ($i.followersCount >= 10) claimAchievement('followers10');
+		if ($i.followersCount >= 50) claimAchievement('followers50');
+		if ($i.followersCount >= 100) claimAchievement('followers100');
+		if ($i.followersCount >= 300) claimAchievement('followers300');
+		if ($i.followersCount >= 500) claimAchievement('followers500');
+		if ($i.followersCount >= 1000) claimAchievement('followers1000');
+
+		if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365) {
+			claimAchievement('passedSinceAccountCreated1');
+		}
+		if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 2) {
+			claimAchievement('passedSinceAccountCreated2');
+		}
+		if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 3) {
+			claimAchievement('passedSinceAccountCreated3');
+		}
+
+		if (claimedAchievements.length >= 30) {
+			claimAchievement('collectAchievements30');
+		}
+	
+		window.setInterval(() => {
+			if (Math.floor(Math.random() * 10000) === 0) {
+				claimAchievement('justPlainLucky');
+			}
+		}, 1000 * 10);
+
+		window.setTimeout(() => {
+			claimAchievement('client30min');
+		}, 1000 * 60 * 30);
+
 		const lastUsed = miLocalStorage.getItem('lastUsed');
 		if (lastUsed) {
 			const lastUsedDate = parseInt(lastUsed, 10);
diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts
index 9ee78741dc..3d16a52e62 100644
--- a/packages/frontend/src/navbar.ts
+++ b/packages/frontend/src/navbar.ts
@@ -1,11 +1,11 @@
 import { computed, ref, reactive } from 'vue';
 import { $i } from './account';
+import { miLocalStorage } from './local-storage';
 import { search } from '@/scripts/search';
 import * as os from '@/os';
 import { i18n } from '@/i18n';
 import { ui } from '@/config';
 import { unisonReload } from '@/scripts/unison-reload';
-import { miLocalStorage } from './local-storage';
 
 export const navbarItemDef = reactive({
 	notifications: {
@@ -103,6 +103,12 @@ export const navbarItemDef = reactive({
 		icon: 'ti ti-device-tv',
 		to: '/channels',
 	},
+	achievements: {
+		title: i18n.ts.achievements,
+		icon: 'ti ti-military-award',
+		show: computed(() => $i != null),
+		to: '/my/achievements',
+	},
 	ui: {
 		title: i18n.ts.switchUi,
 		icon: 'ti ti-devices',
diff --git a/packages/frontend/src/pages/achievements.vue b/packages/frontend/src/pages/achievements.vue
new file mode 100644
index 0000000000..b6cd174b41
--- /dev/null
+++ b/packages/frontend/src/pages/achievements.vue
@@ -0,0 +1,25 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader/></template>
+	<MkSpacer :content-max="1200">
+		<MkAchievements :user="$i"/>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import MkAchievements from '@/components/MkAchievements.vue';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { $i } from '@/account';
+
+definePageMetadata({
+	title: i18n.ts.achievements,
+	icon: 'ti ti-military-award',
+});
+</script>
+
+<style lang="scss" module>
+
+</style>
diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue
index ae74224db6..da7d3d3703 100644
--- a/packages/frontend/src/pages/settings/profile.vue
+++ b/packages/frontend/src/pages/settings/profile.vue
@@ -85,6 +85,7 @@ import { i18n } from '@/i18n';
 import { $i } from '@/account';
 import { langmap } from '@/scripts/langmap';
 import { definePageMetadata } from '@/scripts/page-metadata';
+import { claimAchievement } from '@/scripts/achievements';
 
 const profile = reactive({
 	name: $i.name,
@@ -133,6 +134,13 @@ function save() {
 		isCat: !!profile.isCat,
 		showTimelineReplies: !!profile.showTimelineReplies,
 	});
+	claimAchievement('profileFilled');
+	if (profile.name === 'syuilo' || profile.name === 'しゅいろ') {
+		claimAchievement('setNameToSyuilo');
+	}
+	if (profile.isCat) {
+		claimAchievement('markedAsCat');
+	}
 }
 
 function changeAvatar(ev) {
@@ -155,6 +163,7 @@ function changeAvatar(ev) {
 		});
 		$i.avatarId = i.avatarId;
 		$i.avatarUrl = i.avatarUrl;
+		claimAchievement('profileFilled');
 	});
 }
 
diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts
index 26c73c610f..22106e1595 100644
--- a/packages/frontend/src/router.ts
+++ b/packages/frontend/src/router.ts
@@ -427,6 +427,10 @@ export const routes = [{
 	path: '/my/favorites',
 	component: page(() => import('./pages/favorites.vue')),
 	loginRequired: true,
+}, {
+	path: '/my/achievements',
+	component: page(() => import('./pages/achievements.vue')),
+	loginRequired: true,
 }, {
 	name: 'messaging',
 	path: '/my/messaging',
diff --git a/packages/frontend/src/scripts/achievements.ts b/packages/frontend/src/scripts/achievements.ts
new file mode 100644
index 0000000000..c8245ad3db
--- /dev/null
+++ b/packages/frontend/src/scripts/achievements.ts
@@ -0,0 +1,425 @@
+import * as os from '@/os';
+import { $i } from '@/account';
+
+export const ACHIEVEMENT_TYPES = [
+	'notes1',
+	'notes10',
+	'notes100',
+	'notes500',
+	'notes1000',
+	'notes5000',
+	'notes10000',
+	'notes20000',
+	'notes30000',
+	'notes40000',
+	'notes50000',
+	'notes60000',
+	'notes70000',
+	'notes80000',
+	'notes90000',
+	'notes100000',
+	'login3',
+	'login7',
+	'login15',
+	'login30',
+	'login60',
+	'login100',
+	'login200',
+	'login300',
+	'login400',
+	'login500',
+	'login600',
+	'login700',
+	'login800',
+	'login900',
+	'login1000',
+	'passedSinceAccountCreated1',
+	'passedSinceAccountCreated2',
+	'passedSinceAccountCreated3',
+	'loggedInOnBirthday',
+	'noteClipped1',
+	'noteFavorited1',
+	'profileFilled',
+	'markedAsCat',
+	'following1',
+	'following10',
+	'following50',
+	'following100',
+	'following300',
+	'followers1',
+	'followers10',
+	'followers50',
+	'followers100',
+	'followers300',
+	'followers500',
+	'followers1000',
+	'collectAchievements30',
+	'iLoveMisskey',
+	'client30min',
+	'noteDeletedWithin1min',
+	'postedAtLateNight',
+	'postedAt0min0sec',
+	'selfQuote',
+	'htl20npm',
+	'driveFolderCircularReference',
+	'reactWithoutRead',
+	'clickedClickHere',
+	'justPlainLucky',
+	'setNameToSyuilo',
+	'cookieClicked',
+	'brainDiver',
+] as const;
+
+export const ACHIEVEMENT_BADGES = {
+	'notes1': {
+		img: '/fluent-emoji/1f4dd.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'notes10': {
+		img: '/fluent-emoji/1f4d1.png',
+		bg: null,
+		frame: 'bronze',
+	},
+	'notes100': {
+		img: '/fluent-emoji/1f4d2.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'notes500': {
+		img: '/fluent-emoji/1f4da.png',
+		bg: null,
+		frame: 'bronze',
+	},
+	'notes1000': {
+		img: '/fluent-emoji/1f5c3.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'notes5000': {
+		img: '/fluent-emoji/1f304.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'notes10000': {
+		img: '/fluent-emoji/1f3d9.png',
+		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
+		frame: 'silver',
+	},
+	'notes20000': {
+		img: '/fluent-emoji/1f307.png',
+		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
+		frame: 'silver',
+	},
+	'notes30000': {
+		img: '/fluent-emoji/1f306.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'silver',
+	},
+	'notes40000': {
+		img: '/fluent-emoji/1f303.png',
+		bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
+		frame: 'silver',
+	},
+	'notes50000': {
+		img: '/fluent-emoji/1fa90.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'gold',
+	},
+	'notes60000': {
+		img: '/fluent-emoji/2604.png',
+		bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
+		frame: 'gold',
+	},
+	'notes70000': {
+		img: '/fluent-emoji/1f30c.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'gold',
+	},
+	'notes80000': {
+		img: '/fluent-emoji/1f30c.png',
+		bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
+		frame: 'gold',
+	},
+	'notes90000': {
+		img: '/fluent-emoji/1f30c.png',
+		bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))',
+		frame: 'gold',
+	},
+	'notes100000': {
+		img: '/fluent-emoji/267e.png',
+		bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))',
+		frame: 'platinum',
+	},
+	'login3': {
+		img: '/fluent-emoji/1f331.png',
+		bg: null,
+		frame: 'bronze',
+	},
+	'login7': {
+		img: '/fluent-emoji/1f331.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'login15': {
+		img: '/fluent-emoji/1f331.png',
+		bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
+		frame: 'bronze',
+	},
+	'login30': {
+		img: '/fluent-emoji/1fab4.png',
+		bg: null,
+		frame: 'bronze',
+	},
+	'login60': {
+		img: '/fluent-emoji/1fab4.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'login100': {
+		img: '/fluent-emoji/1fab4.png',
+		bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
+		frame: 'silver',
+	},
+	'login200': {
+		img: '/fluent-emoji/1f333.png',
+		bg: null,
+		frame: 'silver',
+	},
+	'login300': {
+		img: '/fluent-emoji/1f333.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'silver',
+	},
+	'login400': {
+		img: '/fluent-emoji/1f333.png',
+		bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
+		frame: 'silver',
+	},
+	'login500': {
+		img: '/fluent-emoji/1f304.png',
+		bg: null,
+		frame: 'silver',
+	},
+	'login600': {
+		img: '/fluent-emoji/1f304.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'gold',
+	},
+	'login700': {
+		img: '/fluent-emoji/1f304.png',
+		bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
+		frame: 'gold',
+	},
+	'login800': {
+		img: '/fluent-emoji/1f307.png',
+		bg: null,
+		frame: 'gold',
+	},
+	'login900': {
+		img: '/fluent-emoji/1f307.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'gold',
+	},
+	'login1000': {
+		img: '/fluent-emoji/1f307.png',
+		bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
+		frame: 'platinum',
+	},
+	'noteClipped1': {
+		img: '/fluent-emoji/1f587.png',
+		bg: null,
+		frame: 'bronze',
+	},
+	'noteFavorited1': {
+		img: '/fluent-emoji/1f31f.png',
+		bg: null,
+		frame: 'bronze',
+	},
+	'profileFilled': {
+		img: '/fluent-emoji/1f44c.png',
+		bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
+		frame: 'bronze',
+	},
+	'markedAsCat': {
+		img: '/fluent-emoji/1f408.png',
+		bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
+		frame: 'bronze',
+	},
+	'following1': {
+		img: '/fluent-emoji/2618.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'following10': {
+		img: '/fluent-emoji/1f6b8.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'following50': {
+		img: '/fluent-emoji/1f91d.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'following100': {
+		img: '/fluent-emoji/1f4af.png',
+		bg: 'linear-gradient(0deg, rgb(255 53 184), rgb(255 206 69))',
+		frame: 'silver',
+	},
+	'following300': {
+		img: '/fluent-emoji/1f970.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'silver',
+	},
+	'followers1': {
+		img: '/fluent-emoji/2618.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'followers10': {
+		img: '/fluent-emoji/1f44b.png',
+		bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
+		frame: 'bronze',
+	},
+	'followers50': {
+		img: '/fluent-emoji/1f411.png',
+		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
+		frame: 'bronze',
+	},
+	'followers100': {
+		img: '/fluent-emoji/1f396.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'silver',
+	},
+	'followers300': {
+		img: '/fluent-emoji/1f3c6.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'silver',
+	},
+	'followers500': {
+		img: '/fluent-emoji/1f4e1.png',
+		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
+		frame: 'gold',
+	},
+	'followers1000': {
+		img: '/fluent-emoji/1f451.png',
+		bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))',
+		frame: 'platinum',
+	},
+	'collectAchievements30': {
+		img: '/fluent-emoji/1f3c5.png',
+		bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
+		frame: 'silver',
+	},
+	'iLoveMisskey': {
+		img: '/fluent-emoji/2764.png',
+		bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
+		frame: 'silver',
+	},
+	'client30min': {
+		img: '/fluent-emoji/1f552.png',
+		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
+		frame: 'bronze',
+	},
+	'noteDeletedWithin1min': {
+		img: '/fluent-emoji/1f5d1.png',
+		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
+		frame: 'bronze',
+	},
+	'postedAtLateNight': {
+		img: '/fluent-emoji/1f319.png',
+		bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
+		frame: 'bronze',
+	},
+	'postedAt0min0sec': {
+		img: '/fluent-emoji/1f55b.png',
+		bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))',
+		frame: 'bronze',
+	},
+	'selfQuote': {
+		img: '/fluent-emoji/1f4dd.png',
+		bg: null,
+		frame: 'bronze',
+	},
+	'htl20npm': {
+		img: '/fluent-emoji/1f30a.png',
+		bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
+		frame: 'bronze',
+	},
+	'driveFolderCircularReference': {
+		img: '/fluent-emoji/1f4c2.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'bronze',
+	},
+	'reactWithoutRead': {
+		img: '/fluent-emoji/2753.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'bronze',
+	},
+	'clickedClickHere': {
+		img: '/fluent-emoji/2757.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'bronze',
+	},
+	'justPlainLucky': {
+		img: '/fluent-emoji/1f340.png',
+		bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
+		frame: 'silver',
+	},
+	'setNameToSyuilo': {
+		img: '/fluent-emoji/1f36e.png',
+		bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
+		frame: 'bronze',
+	},
+	'passedSinceAccountCreated1': {
+		img: '/fluent-emoji/0031-20e3.png',
+		bg: null,
+		frame: 'bronze',
+	},
+	'passedSinceAccountCreated2': {
+		img: '/fluent-emoji/0032-20e3.png',
+		bg: null,
+		frame: 'silver',
+	},
+	'passedSinceAccountCreated3': {
+		img: '/fluent-emoji/0033-20e3.png',
+		bg: null,
+		frame: 'gold',
+	},
+	'loggedInOnBirthday': {
+		img: '/fluent-emoji/1f382.png',
+		bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
+		frame: 'silver',
+	},
+	'cookieClicked': {
+		img: '/fluent-emoji/1f36a.png',
+		bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
+		frame: 'bronze',
+	},
+	'brainDiver': {
+		img: '/fluent-emoji/1f9e0.png',
+		bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
+		frame: 'bronze',
+	},
+} as const satisfies Record<typeof ACHIEVEMENT_TYPES[number], {
+	img: string;
+	bg: string | null;
+	frame: 'bronze' | 'silver' | 'gold' | 'platinum';
+}>;
+
+export const claimedAchievements = ($i && $i.achievements) ? $i.achievements.map(x => x.name) : [];
+
+export function claimAchievement(type: typeof ACHIEVEMENT_TYPES[number]) {
+	if (claimedAchievements.includes(type)) return;
+	os.api('i/claim-achievement', { name: type });
+	claimedAchievements.push(type);
+}
+
+if (_DEV_) {
+	(window as any).unlockAllAchievements = async () => {
+		for (const t of ACHIEVEMENT_TYPES) {
+			await new Promise(resolve => setTimeout(resolve, 100));
+			claimAchievement(t);
+		}
+	};
+}
diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts
index 7a426ec722..da7f2a5c20 100644
--- a/packages/frontend/src/scripts/get-note-menu.ts
+++ b/packages/frontend/src/scripts/get-note-menu.ts
@@ -1,6 +1,7 @@
 import { defineAsyncComponent, Ref, inject } from 'vue';
 import * as misskey from 'misskey-js';
 import { pleaseLogin } from './please-login';
+import { claimAchievement } from './achievements';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
 import { instance } from '@/instance';
@@ -38,6 +39,10 @@ export function getNoteMenu(props: {
 			os.api('notes/delete', {
 				noteId: appearNote.id,
 			});
+
+			if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60) {
+				claimAchievement('noteDeletedWithin1min');
+			}
 		});
 	}
 
@@ -53,10 +58,15 @@ export function getNoteMenu(props: {
 			});
 
 			os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel });
+
+			if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60) {
+				claimAchievement('noteDeletedWithin1min');
+			}
 		});
 	}
 
 	function toggleFavorite(favorite: boolean): void {
+		claimAchievement('noteFavorited1');
 		os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
 			noteId: appearNote.id,
 		});
@@ -118,11 +128,13 @@ export function getNoteMenu(props: {
 
 				const clip = await os.apiWithDialog('clips/create', result);
 
+				claimAchievement('noteClipped1');
 				os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
 			},
 		}, null, ...clips.map(clip => ({
 			text: clip.name,
 			action: () => {
+				claimAchievement('noteClipped1');
 				os.promiseDialog(
 					os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }),
 					null,